Files
coyote/src/sandbox/mcp_credentials.rs
T

1153 lines
36 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use serde_json::Value;
use url::Url;
use crate::vault::SECRET_RE;
pub(crate) const MCP_MIXIN_NAME: &str = "coyote-mcp";
const ENV_VAR_PREFIX: &str = "COYOTE_SECRET_";
const SERVICE_ID_MAX_LEN: usize = 63;
pub(crate) fn secret_service_id(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_dash = false;
for ch in name.chars() {
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
out.push(lower);
last_was_dash = false;
} else if !last_was_dash {
out.push('-');
last_was_dash = true;
}
}
let mut id: String = out
.trim_matches('-')
.chars()
.take(SERVICE_ID_MAX_LEN)
.collect();
while id.ends_with('-') {
id.pop();
}
id
}
pub(crate) fn sandbox_secret_env_var(name: &str) -> String {
let mut out = String::from(ENV_VAR_PREFIX);
for ch in name.chars() {
let upper = ch.to_ascii_uppercase();
if upper.is_ascii_uppercase() || upper.is_ascii_digit() {
out.push(upper);
} else {
out.push('_');
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub(crate) struct InjectRule {
pub domain: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
}
impl InjectRule {
fn effective_header(&self) -> String {
self.header
.as_deref()
.unwrap_or("Authorization")
.to_ascii_lowercase()
}
}
/// One credential to register with sbx and declare in the generated mixin.
#[derive(Debug)]
pub(crate) struct CredentialSpec {
pub secret_name: String,
pub service_id: String,
pub env_var: String,
pub proxy_managed: bool,
pub inject: Vec<InjectRule>,
pub servers: Vec<String>,
}
struct Occurrence {
secret_name: String,
/// `Some` when the occurrence can be rewritten by the sandbox proxy.
inject: Option<InjectRule>,
}
struct PlaceholderMatch {
name: String,
start: usize,
end: usize,
}
/// Collects every `{{placeholder}}` across all MCP servers and folds them into
/// one credential per distinct secret name. A secret is proxy-managed only when
/// every occurrence of it (across all servers) is a proxy-injectable header
/// value AND no other secret targets the same (domain, header) injection slot
/// (proxy inject rules are keyed by domain + header, not by server).
pub(crate) fn collect_credentials(
servers: &serde_json::Map<String, Value>,
) -> Result<Vec<CredentialSpec>> {
struct Aggregate {
all_injectable: bool,
inject: BTreeSet<InjectRule>,
servers: BTreeSet<String>,
}
let mut by_secret: BTreeMap<String, Aggregate> = BTreeMap::new();
for (server_name, config) in servers {
for occurrence in collect_server_occurrences(config)? {
let agg = by_secret
.entry(occurrence.secret_name)
.or_insert_with(|| Aggregate {
all_injectable: true,
inject: BTreeSet::new(),
servers: BTreeSet::new(),
});
agg.servers.insert(server_name.clone());
match occurrence.inject {
Some(rule) => {
agg.inject.insert(rule);
}
None => agg.all_injectable = false,
}
}
}
// The proxy injects per (domain, header), not per server: sbx kits v2
// spec supports multiple credentials on one domain as long as they
// write different headers. A conflict exists only when two different
// secrets target the SAME header on the same domain, then the proxy
// cannot tell which credential a given request needs, and it could
// clobber a header Coyote resolved from the environment. Demote
// every secret involved in such a collision to env-based resolution.
let mut secrets_by_slot: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for (secret_name, agg) in &by_secret {
for rule in &agg.inject {
secrets_by_slot
.entry((rule.domain.clone(), rule.effective_header()))
.or_default()
.insert(secret_name.clone());
}
}
let mut slot_conflicted: BTreeSet<String> = BTreeSet::new();
for ((domain, header), secrets) in &secrets_by_slot {
if secrets.len() > 1 {
eprintln!(
"MCP secrets {} all target the '{header}' header for '{domain}'. The \
sandbox proxy cannot tell which one a given request needs, so these \
secrets will be resolved from environment variables inside the \
sandbox instead.",
quoted_list(secrets)
);
slot_conflicted.extend(secrets.iter().cloned());
}
}
let mut credentials = Vec::with_capacity(by_secret.len());
let mut secrets_by_service_id: BTreeMap<String, String> = BTreeMap::new();
let mut secrets_by_env_var: BTreeMap<String, String> = BTreeMap::new();
for (secret_name, agg) in by_secret {
let service_id = secret_service_id(&secret_name);
if service_id.is_empty() {
bail!(
"Secret name '{secret_name}' referenced by MCP server(s) {} sanitizes to an \
empty sbx service id",
quoted_list(&agg.servers)
);
}
if let Some(existing) =
secrets_by_service_id.insert(service_id.clone(), secret_name.clone())
{
bail!(
"MCP secrets '{existing}' and '{secret_name}' both sanitize to sbx service id \
'{service_id}'; rename one"
);
}
let env_var = sandbox_secret_env_var(&secret_name);
if let Some(existing) = secrets_by_env_var.insert(env_var.clone(), secret_name.clone()) {
bail!(
"MCP secrets '{existing}' and '{secret_name}' both sanitize to env var \
'{env_var}'; rename one"
);
}
let proxy_managed = agg.all_injectable && !slot_conflicted.contains(&secret_name);
credentials.push(CredentialSpec {
secret_name,
service_id,
env_var,
proxy_managed,
inject: if proxy_managed {
agg.inject.into_iter().collect()
} else {
Vec::new()
},
servers: agg.servers.into_iter().collect(),
});
}
credentials.sort_by(|a, b| a.service_id.cmp(&b.service_id));
Ok(credentials)
}
pub(crate) fn quoted_list<'a, I: IntoIterator<Item = &'a String>>(items: I) -> String {
items
.into_iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn collect_server_occurrences(server: &Value) -> Result<Vec<Occurrence>> {
let Some(fields) = server.as_object() else {
return Ok(Vec::new());
};
let https_domain = fields
.get("url")
.and_then(Value::as_str)
.and_then(parse_https_domain);
let mut out = Vec::new();
for (key, value) in fields {
if key == "headers"
&& let Some(headers) = value.as_object()
{
for (header, header_value) in headers {
match header_value.as_str() {
Some(s) => classify_header_value(header, s, https_domain.as_deref(), &mut out)?,
None => collect_env_occurrences(header_value, &mut out)?,
}
}
continue;
}
collect_env_occurrences(value, &mut out)?;
}
Ok(out)
}
fn collect_env_occurrences(value: &Value, out: &mut Vec<Occurrence>) -> Result<()> {
match value {
Value::String(s) => {
for m in placeholders(s)? {
out.push(Occurrence {
secret_name: m.name,
inject: None,
});
}
}
Value::Object(map) => {
for v in map.values() {
collect_env_occurrences(v, out)?;
}
}
Value::Array(arr) => {
for v in arr {
collect_env_occurrences(v, out)?;
}
}
_ => {}
}
Ok(())
}
/// A header value is proxy-injectable only when the server URL is https with a
/// parseable host, the value holds exactly one placeholder, and the literal
/// remainder contains no `%`.
fn classify_header_value(
header: &str,
value: &str,
https_domain: Option<&str>,
out: &mut Vec<Occurrence>,
) -> Result<()> {
let matches = placeholders(value)?;
if matches.is_empty() {
return Ok(());
}
let rule = match (https_domain, matches.as_slice()) {
(Some(domain), [only]) => {
let remainder = format!("{}{}", &value[..only.start], &value[only.end..]);
if remainder.contains('%') {
None
} else {
Some(build_inject_rule(domain, header, value, only))
}
}
_ => None,
};
match rule {
Some(rule) => {
let m = matches.into_iter().next().expect("checked non-empty");
out.push(Occurrence {
secret_name: m.name,
inject: Some(rule),
});
}
None => {
for m in matches {
out.push(Occurrence {
secret_name: m.name,
inject: None,
});
}
}
}
Ok(())
}
fn build_inject_rule(domain: &str, header: &str, value: &str, m: &PlaceholderMatch) -> InjectRule {
let is_bearer = header.eq_ignore_ascii_case("authorization")
&& value[..m.start].eq_ignore_ascii_case("Bearer ")
&& value[m.end..].is_empty();
if is_bearer {
InjectRule {
domain: domain.to_string(),
header: None,
format: None,
scheme: Some("bearer".to_string()),
}
} else {
InjectRule {
domain: domain.to_string(),
header: Some(header.to_string()),
format: Some(format!("{}%s{}", &value[..m.start], &value[m.end..])),
scheme: None,
}
}
}
/// Returns the inject-rule `domain` for an https URL: the bare host on the
/// default port (443), or `host:port` for any other port. This mirrors the
/// enforced allow-list entry grammar. Bracketed IPv6 hosts and non-https
/// schemes are rejected so their occurrences fall back to env-based
/// resolution.
fn parse_https_domain(raw: &str) -> Option<String> {
let url = Url::parse(raw).ok()?;
if url.scheme() != "https" {
return None;
}
let host = url.host_str()?;
if host.is_empty() || host.starts_with('[') {
return None;
}
match url.port() {
None => Some(host.to_string()),
Some(port) => Some(format!("{host}:{port}")),
}
}
/// Collects a network allow-list entry for every remote MCP server `url`
/// (http/https), so user-configured servers are reachable regardless of how,
/// or whether, their credentials are provisioned. Https on the default port
/// yields a bare host; any other port is spelled `host:port` (both enforced
/// entry formats). Bracketed IPv6 hosts are skipped — the sbx kit v2 spec's
/// allow grammar only covers named hosts.
pub(crate) fn collect_server_allow_entries(
servers: &serde_json::Map<String, Value>,
) -> Vec<String> {
let mut out = BTreeSet::new();
for config in servers.values() {
let Some(url) = config.get("url").and_then(Value::as_str) else {
continue;
};
if let Some(entry) = allow_entry_for_url(url) {
out.insert(entry);
}
}
out.into_iter().collect()
}
pub(crate) fn allow_entry_for_url(raw: &str) -> Option<String> {
let url = Url::parse(raw).ok()?;
let scheme = url.scheme();
if scheme != "https" && scheme != "http" {
return None;
}
let host = url.host_str()?;
if host.is_empty() || host.starts_with('[') {
return None;
}
match url.port_or_known_default() {
Some(443) if scheme == "https" => Some(host.to_string()),
Some(port) => Some(format!("{host}:{port}")),
None => None,
}
}
fn placeholders(text: &str) -> Result<Vec<PlaceholderMatch>> {
let mut out = Vec::new();
for caps in SECRET_RE.captures_iter(text) {
let caps = caps.context("Failed to scan for secret placeholders")?;
let full = caps.get(0).expect("capture group 0 always exists");
out.push(PlaceholderMatch {
name: caps[1].trim().to_string(),
start: full.start(),
end: full.end(),
});
}
Ok(out)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CredentialsMixin {
schema_version: &'static str,
kind: &'static str,
name: String,
description: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
credentials: Vec<CredentialEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
permissions: Option<Permissions>,
}
#[derive(Serialize)]
pub(crate) struct CredentialEntry {
pub service: String,
pub description: String,
#[serde(rename = "apiKey")]
pub api_key: ApiKey,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiKey {
pub name: String,
pub proxy_managed: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub inject: Vec<InjectRule>,
}
#[derive(Serialize)]
struct Permissions {
network: Network,
}
#[derive(Serialize)]
struct Network {
allow: Vec<String>,
}
pub(crate) fn render_mixin_document(
name: &str,
description: &str,
credentials: Vec<CredentialEntry>,
extra_allow_entries: &[String],
) -> Result<String> {
let mut allow: BTreeSet<String> = credentials
.iter()
.flat_map(|c| c.api_key.inject.iter().map(|r| r.domain.clone()))
.collect();
allow.extend(extra_allow_entries.iter().cloned());
let mixin = CredentialsMixin {
schema_version: "2",
kind: "mixin",
name: name.to_string(),
description: description.to_string(),
credentials,
permissions: (!allow.is_empty()).then(|| Permissions {
network: Network {
allow: allow.into_iter().collect(),
},
}),
};
serde_yaml::to_string(&mixin).context("Failed to serialize generated sandbox mixin")
}
pub(crate) fn render_mixin_yaml(
credentials: &[CredentialSpec],
server_allow_entries: &[String],
) -> Result<String> {
let entries = credentials
.iter()
.map(|c| CredentialEntry {
service: c.service_id.clone(),
description: format!(
"Coyote vault secret '{}', used by MCP server(s) {}",
c.secret_name,
quoted_list(&c.servers)
),
api_key: ApiKey {
name: c.env_var.clone(),
proxy_managed: c.proxy_managed,
inject: c.inject.clone(),
},
})
.collect();
render_mixin_document(
MCP_MIXIN_NAME,
"Auto-generated by Coyote at launch: allows network egress to the user's remote MCP \
servers and declares their credentials so Docker Sandboxes binds them (bindings are \
approved on first interactive run). Values are pre-seeded from Coyote's vault via \
`sbx secret set`.",
entries,
server_allow_entries,
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map, json};
fn servers(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
#[test]
fn secret_service_id_lowercases_and_dashes() {
assert_eq!(secret_service_id("GITHUB_PAT"), "github-pat");
}
#[test]
fn secret_service_id_collapses_and_trims() {
assert_eq!(secret_service_id("__My..Token__"), "my-token");
}
#[test]
fn secret_service_id_truncates_without_trailing_dash() {
let long = format!("{}-{}", "a".repeat(62), "b".repeat(10));
let id = secret_service_id(&long);
assert_eq!(id.len(), 62, "trailing dash at char 63 must be dropped");
assert!(!id.ends_with('-'));
}
#[test]
fn secret_service_id_all_invalid_yields_empty() {
assert_eq!(secret_service_id("..."), "");
}
#[test]
fn sandbox_secret_env_var_uppercases_and_underscores() {
assert_eq!(
sandbox_secret_env_var("GITHUB_PAT"),
"COYOTE_SECRET_GITHUB_PAT"
);
assert_eq!(
sandbox_secret_env_var("notion-token"),
"COYOTE_SECRET_NOTION_TOKEN"
);
}
#[test]
fn collects_every_placeholder_not_just_the_first() {
let servers = servers(json!({
"multi": {
"command": "run",
"args": ["--token", "{{TOKEN_A}}"],
"env": {
"FIRST": "{{TOKEN_B}}",
"COMBINED": "{{TOKEN_C}}:{{TOKEN_D}}"
}
}
}));
let creds = collect_credentials(&servers).unwrap();
let names: Vec<&str> = creds.iter().map(|c| c.secret_name.as_str()).collect();
assert_eq!(names, vec!["TOKEN_A", "TOKEN_B", "TOKEN_C", "TOKEN_D"]);
assert!(creds.iter().all(|c| !c.proxy_managed));
}
#[test]
fn bearer_authorization_header_is_proxy_managed_with_bearer_scheme() {
let servers = servers(json!({
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": { "Authorization": "Bearer {{GITHUB_PAT}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
let cred = &creds[0];
assert_eq!(cred.service_id, "github-pat");
assert_eq!(cred.env_var, "COYOTE_SECRET_GITHUB_PAT");
assert!(cred.proxy_managed);
assert_eq!(
cred.inject,
vec![InjectRule {
domain: "api.githubcopilot.com".to_string(),
header: None,
format: None,
scheme: Some("bearer".to_string()),
}]
);
}
#[test]
fn custom_header_becomes_format_rule() {
let servers = servers(json!({
"notion": {
"url": "https://mcp.notion.com/sse",
"headers": { "X-Api-Key": "token {{notion-token}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(creds[0].proxy_managed);
assert_eq!(
creds[0].inject,
vec![InjectRule {
domain: "mcp.notion.com".to_string(),
header: Some("X-Api-Key".to_string()),
format: Some("token %s".to_string()),
scheme: None,
}]
);
}
#[test]
fn lowercase_bearer_prefix_is_proxy_managed() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com/mcp",
"headers": { "authorization": "bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(creds[0].proxy_managed);
assert_eq!(creds[0].inject[0].scheme.as_deref(), Some("bearer"));
assert!(creds[0].inject[0].header.is_none());
}
#[test]
fn non_default_https_port_is_proxy_managed_with_port_in_domain() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com:8443/mcp",
"headers": { "Authorization": "Bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(creds[0].proxy_managed);
assert_eq!(
creds[0].inject,
vec![InjectRule {
domain: "api.example.com:8443".to_string(),
header: None,
format: None,
scheme: Some("bearer".to_string()),
}]
);
}
#[test]
fn port_inject_domain_is_included_in_rendered_allow_list() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com:8443/mcp",
"headers": { "Authorization": "Bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
let allow = collect_server_allow_entries(&servers);
let yaml = render_mixin_yaml(&creds, &allow).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let rendered_allow = value["permissions"]["network"]["allow"]
.as_sequence()
.unwrap();
assert_eq!(
rendered_allow
.iter()
.filter(|v| v.as_str() == Some("api.example.com:8443"))
.count(),
1,
"host:port must appear exactly once (inject domain deduped against server allow entry)"
);
}
#[test]
fn explicit_default_port_is_omitted_from_domain() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com:443/mcp",
"headers": { "Authorization": "Bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds[0].inject[0].domain, "api.example.com");
}
#[test]
fn multi_placeholder_header_falls_back_to_env() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Basic {{USER}}:{{PASS}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
assert!(creds.iter().all(|c| !c.proxy_managed));
assert!(creds.iter().all(|c| c.inject.is_empty()));
}
#[test]
fn percent_in_header_remainder_falls_back_to_env() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com/mcp",
"headers": { "X-Key": "100%-{{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert!(!creds[0].proxy_managed);
}
#[test]
fn non_https_url_falls_back_to_env() {
let servers = servers(json!({
"svc": {
"url": "http://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert!(!creds[0].proxy_managed);
}
#[test]
fn url_placeholder_is_env_based() {
let servers = servers(json!({
"svc": { "url": "https://api.example.com/mcp?key={{KEY}}" }
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds[0].secret_name, "KEY");
assert!(!creds[0].proxy_managed);
}
#[test]
fn env_occurrence_in_another_server_demotes_proxy_managed() {
let servers = servers(json!({
"remote": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{SHARED}}" }
},
"local": {
"command": "run",
"env": { "SHARED": "{{SHARED}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(!creds[0].proxy_managed);
assert!(creds[0].inject.is_empty());
assert_eq!(creds[0].servers, vec!["local", "remote"]);
}
#[test]
fn identical_inject_rules_across_servers_are_deduped() {
let servers = servers(json!({
"a": {
"url": "https://api.example.com/one",
"headers": { "Authorization": "Bearer {{KEY}}" }
},
"b": {
"url": "https://api.example.com/two",
"headers": { "Authorization": "Bearer {{KEY}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(creds[0].proxy_managed);
assert_eq!(creds[0].inject.len(), 1);
}
#[test]
fn same_domain_different_secrets_demotes_both_to_env() {
let servers = servers(json!({
"github-personal": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": { "Authorization": "Bearer {{GITHUB_PAT_PERSONAL}}" }
},
"github-work": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": { "Authorization": "Bearer {{GITHUB_PAT_WORK}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
assert!(
creds.iter().all(|c| !c.proxy_managed),
"secrets sharing an inject domain must both fall back to env"
);
assert!(creds.iter().all(|c| c.inject.is_empty()));
}
#[test]
fn slot_conflict_demotes_secret_on_all_its_domains() {
let servers = servers(json!({
"a-shared": {
"url": "https://shared.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_A}}" }
},
"a-solo": {
"url": "https://solo.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_A}}" }
},
"b-shared": {
"url": "https://shared.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_B}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
assert!(
creds
.iter()
.all(|c| !c.proxy_managed && c.inject.is_empty()),
"a secret demoted by a slot conflict must lose ALL its inject rules, \
not just the conflicting one"
);
}
#[test]
fn different_headers_on_same_domain_stay_proxy_managed() {
let servers = servers(json!({
"svc": {
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer {{KEY_A}}",
"X-Api-Key": "{{KEY_B}}"
}
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
assert!(
creds.iter().all(|c| c.proxy_managed),
"v2 allows multiple credentials per domain when they target \
different headers; neither secret may be demoted"
);
let a = creds.iter().find(|c| c.secret_name == "KEY_A").unwrap();
assert_eq!(a.inject[0].scheme.as_deref(), Some("bearer"));
let b = creds.iter().find(|c| c.secret_name == "KEY_B").unwrap();
assert_eq!(b.inject[0].header.as_deref(), Some("X-Api-Key"));
}
#[test]
fn bearer_scheme_conflicts_with_explicit_authorization_header() {
let servers = servers(json!({
"bearer-style": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_A}}" }
},
"token-style": {
"url": "https://api.example.com/mcp",
"headers": { "authorization": "token {{KEY_B}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
assert!(
creds
.iter()
.all(|c| !c.proxy_managed && c.inject.is_empty()),
"`scheme: bearer` writes the Authorization header, so it must \
conflict with an explicit (case-insensitive) Authorization rule"
);
}
#[test]
fn env_based_secret_header_occurrence_demotes_injectable_sharer() {
let servers = servers(json!({
"remote": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_A}}" }
},
"remote-twin": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_B}}" }
},
"local": {
"command": "run",
"env": { "KEY_B": "{{KEY_B}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 2);
let a = creds.iter().find(|c| c.secret_name == "KEY_A").unwrap();
assert!(
!a.proxy_managed && a.inject.is_empty(),
"KEY_B is env-based, but its header occurrence on the shared domain \
must still demote KEY_A — the proxy would rewrite KEY_B's requests too"
);
}
#[test]
fn unsanitizable_secret_name_is_an_error() {
let servers = servers(json!({
"svc": { "env": { "KEY": "{{...}}" } }
}));
let err = collect_credentials(&servers).unwrap_err().to_string();
assert!(err.contains("empty sbx service id"), "got: {err}");
}
#[test]
fn colliding_sanitized_secret_names_are_an_error() {
let servers = servers(json!({
"svc": { "env": { "A": "{{MY_KEY}}", "B": "{{my-key}}" } }
}));
let err = collect_credentials(&servers).unwrap_err().to_string();
assert!(
err.contains("'MY_KEY'") && err.contains("'my-key'"),
"error must name both secrets, got: {err}"
);
assert!(
err.contains("service id 'my-key'"),
"error must name the colliding service id, got: {err}"
);
}
#[test]
fn rendered_mixin_declares_proxy_managed_credential_and_permissions() {
let servers = servers(json!({
"github": {
"url": "https://api.githubcopilot.com/mcp/",
"headers": { "Authorization": "Bearer {{GITHUB_PAT}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
let yaml = render_mixin_yaml(&creds, &[]).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(value["schemaVersion"].as_str(), Some("2"));
assert_eq!(value["kind"].as_str(), Some("mixin"));
assert_eq!(value["name"].as_str(), Some("coyote-mcp"));
let cred = &value["credentials"][0];
assert_eq!(cred["service"].as_str(), Some("github-pat"));
let description = cred["description"].as_str().unwrap();
assert!(
description.contains("'GITHUB_PAT'") && description.contains("'github'"),
"binding-prompt description must name the vault secret and its server(s), got: {description}"
);
assert_eq!(
cred["apiKey"]["name"].as_str(),
Some("COYOTE_SECRET_GITHUB_PAT")
);
assert_eq!(cred["apiKey"]["proxyManaged"].as_bool(), Some(true));
let inject = &cred["apiKey"]["inject"][0];
assert_eq!(inject["domain"].as_str(), Some("api.githubcopilot.com"));
assert_eq!(inject["scheme"].as_str(), Some("bearer"));
assert!(inject.get("header").is_none());
assert!(inject.get("format").is_none());
assert_eq!(
value["permissions"]["network"]["allow"][0].as_str(),
Some("api.githubcopilot.com")
);
}
#[test]
fn rendered_mixin_omits_inject_and_permissions_for_env_based_secrets() {
let servers = servers(json!({
"local": { "command": "run", "env": { "KEY": "{{NOTION_TOKEN}}" } }
}));
let creds = collect_credentials(&servers).unwrap();
let yaml = render_mixin_yaml(&creds, &[]).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let cred = &value["credentials"][0];
assert_eq!(cred["apiKey"]["proxyManaged"].as_bool(), Some(false));
assert!(cred["apiKey"].get("inject").is_none());
assert!(value.get("permissions").is_none());
}
#[test]
fn rendered_mixin_is_deterministic() {
let servers = servers(json!({
"b": {
"url": "https://b.example.com/mcp",
"headers": { "Authorization": "Bearer {{ZULU}}" }
},
"a": {
"url": "https://a.example.com/mcp",
"headers": { "Authorization": "Bearer {{ALPHA}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
let services: Vec<&str> = creds.iter().map(|c| c.service_id.as_str()).collect();
assert_eq!(services, vec!["alpha", "zulu"]);
assert_eq!(
render_mixin_yaml(&creds, &[]).unwrap(),
render_mixin_yaml(&creds, &[]).unwrap()
);
}
#[test]
fn allow_entry_for_url_formats() {
assert_eq!(
allow_entry_for_url("https://api.example.com/mcp"),
Some("api.example.com".to_string())
);
assert_eq!(
allow_entry_for_url("https://api.example.com:443/mcp"),
Some("api.example.com".to_string())
);
assert_eq!(
allow_entry_for_url("https://api.example.com:8443/mcp"),
Some("api.example.com:8443".to_string())
);
assert_eq!(
allow_entry_for_url("http://api.example.com/mcp"),
Some("api.example.com:80".to_string())
);
assert_eq!(allow_entry_for_url("ws://api.example.com/mcp"), None);
assert_eq!(allow_entry_for_url("https://[::1]:8443/mcp"), None);
assert_eq!(allow_entry_for_url("not a url"), None);
}
#[test]
fn collect_server_allow_entries_covers_secretless_servers_and_dedupes() {
let servers = servers(json!({
"no-secret": { "url": "https://open.example.com/mcp" },
"no-secret-twin": { "url": "https://open.example.com/mcp" },
"with-secret": {
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY}}" }
},
"stdio": { "command": "uvx", "args": ["some-mcp-server"] }
}));
assert_eq!(
collect_server_allow_entries(&servers),
vec![
"api.example.com".to_string(),
"open.example.com".to_string()
]
);
}
#[test]
fn demoted_secret_server_domain_stays_in_allow() {
let servers = servers(json!({
"a": {
"url": "https://shared.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_A}}" }
},
"b": {
"url": "https://shared.example.com/mcp",
"headers": { "Authorization": "Bearer {{KEY_B}}" }
}
}));
let creds = collect_credentials(&servers).unwrap();
assert!(creds.iter().all(|c| !c.proxy_managed), "precondition");
let entries = collect_server_allow_entries(&servers);
let yaml = render_mixin_yaml(&creds, &entries).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
value["permissions"]["network"]["allow"][0].as_str(),
Some("shared.example.com"),
"demoting a credential must not cut the server's egress"
);
}
#[test]
fn mixin_with_no_credentials_still_declares_network_allow() {
let servers = servers(json!({
"open": { "url": "https://open.example.com/mcp" }
}));
let creds = collect_credentials(&servers).unwrap();
assert!(creds.is_empty(), "precondition: no secrets referenced");
let entries = collect_server_allow_entries(&servers);
let yaml = render_mixin_yaml(&creds, &entries).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert!(
value.get("credentials").is_none(),
"empty credentials list must be omitted under strict decoding hygiene"
);
assert_eq!(
value["permissions"]["network"]["allow"][0].as_str(),
Some("open.example.com")
);
}
}