use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use nu_ansi_term::{Color, Style};
use serde::{Deserialize, Serialize};
use crate::secure_file;
use crate::style::{paint, sanitize, tag};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPlan {
pub source: String,
pub entry: String,
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub env_keys: Vec<String>,
}
impl SpawnPlan {
pub fn new(
source: &Path,
entry: &str,
command: &[String],
cwd: Option<&Path>,
env: &BTreeMap<String, String>,
) -> Self {
let mut env_keys: Vec<String> = env.keys().cloned().collect();
env_keys.sort();
Self {
source: std::fs::canonicalize(source)
.unwrap_or_else(|_| source.to_path_buf())
.display()
.to_string(),
entry: entry.to_string(),
command: command.to_vec(),
cwd: cwd.map(|p| p.display().to_string()),
env_keys,
}
}
fn credential_env_keys(&self) -> Vec<&String> {
self.env_keys
.iter()
.filter(|key| crate::wire::looks_like_credential(key))
.collect()
}
fn command_line(&self) -> String {
self.command
.iter()
.map(|part| {
if part.contains(char::is_whitespace) {
format!("{part:?}")
} else {
part.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct Approvals {
#[serde(default, rename = "approved")]
entries: Vec<SpawnPlan>,
}
pub fn approvals_path(config_path: Option<&Path>) -> Option<PathBuf> {
let directory = config_path?.parent()?;
Some(directory.join("approved-imports.toml"))
}
fn load(path: &Path) -> Approvals {
let Ok(text) = std::fs::read_to_string(path) else {
return Approvals::default();
};
toml::from_str(&text).unwrap_or_default()
}
fn remember(path: &Path, plan: &SpawnPlan) -> Result<(), String> {
let mut approvals = load(path);
if !approvals.entries.iter().any(|known| known == plan) {
approvals.entries.push(plan.clone());
}
let text = toml::to_string_pretty(&approvals)
.map_err(|e| format!("could not encode {}: {e}", path.display()))?;
let document = format!(
"# Imported client-config entries approved for spawning, written by\n\
# mcp-repl. Delete a block to be asked about it again, or delete the\n\
# file to forget every approval.\n\n{text}"
);
secure_file::write_atomic(path, &document)
.map_err(|e| format!("could not write {}: {e}", path.display()))
}
fn is_approved(path: Option<&Path>, plan: &SpawnPlan) -> bool {
path.is_some_and(|path| load(path).entries.iter().any(|known| known == plan))
}
pub enum Decision {
Approved,
Refused(String),
}
pub fn authorize(
plan: &SpawnPlan,
config_path: Option<&Path>,
trusted: bool,
interactive: bool,
) -> Decision {
if trusted {
tracing::debug!(source = %plan.source, entry = %plan.entry, "spawn approved by --trust-import");
return Decision::Approved;
}
let store = approvals_path(config_path);
if is_approved(store.as_deref(), plan) {
tracing::debug!(
source = %plan.source,
entry = %plan.entry,
store = ?store.as_deref().map(|p| p.display().to_string()),
"spawn approved by a recorded approval"
);
return Decision::Approved;
}
tracing::debug!(
source = %plan.source,
entry = %plan.entry,
interactive,
"spawn has no recorded approval"
);
if !interactive {
return Decision::Refused(format!(
"refusing to spawn the server imported from {}:{} without approval.\n\
This entry has not been approved before, and a non-interactive session has \
nobody to ask. Run it once interactively to review and approve it, or pass \
--trust-import to skip the check.",
plan.source, plan.entry
));
}
describe(plan);
match confirm() {
false => Decision::Refused(format!(
"not spawning {}:{}",
plan.source,
sanitize(&plan.entry)
)),
true => {
match store {
Some(path) => {
if let Err(e) = remember(&path, plan) {
eprintln!("warning: {e}");
}
}
None => eprintln!(
"note: no config location, so this approval is not saved and will be \
asked again next time"
),
}
Decision::Approved
}
}
}
fn describe(plan: &SpawnPlan) {
println!(
"{} {}:{} wants to start a server process on this machine:",
tag(Style::new().fg(Color::Yellow), "import"),
sanitize(&plan.source),
sanitize(&plan.entry)
);
println!(
" command: {}",
paint(Style::new().bold(), &sanitize(&plan.command_line()))
);
if let Some(cwd) = &plan.cwd {
println!(" cwd: {}", sanitize(cwd));
}
if !plan.env_keys.is_empty() {
let names: Vec<String> = plan
.env_keys
.iter()
.map(|key| sanitize(key).into_owned())
.collect();
println!(" env: {}", names.join(", "));
}
for key in plan.credential_env_keys() {
println!(
" {} {} looks like a credential, and its value goes to this program",
paint(Style::new().fg(Color::Yellow).bold(), "warning:"),
sanitize(key)
);
}
println!(
"{}",
paint(
Style::new().dimmed(),
"The imported file chooses the program and which of your environment variables \
it receives. Approve it only if you trust that file."
)
);
}
fn confirm() -> bool {
print!(" start it? [y/N]> ");
let _ = std::io::stdout().flush();
let mut buf = String::new();
let read = {
let mut lock = std::io::stdin().lock();
std::io::BufRead::read_line(&mut lock, &mut buf)
};
match read {
Ok(0) | Err(_) => false,
Ok(_) => matches!(buf.trim(), "y" | "Y" | "yes" | "Yes"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plan(command: &[&str]) -> SpawnPlan {
let mut env = BTreeMap::new();
env.insert("API_TOKEN".to_string(), "secret-value".to_string());
env.insert("REGION".to_string(), "us-east-1".to_string());
SpawnPlan::new(
Path::new("/repo/.mcp.json"),
"local",
&command.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
Some(Path::new("/repo")),
&env,
)
}
#[test]
fn a_recorded_plan_is_not_asked_about_again() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("approved-imports.toml");
let plan = plan(&["server", "--stdio"]);
assert!(!is_approved(Some(&store), &plan));
remember(&store, &plan).unwrap();
assert!(is_approved(Some(&store), &plan));
}
#[test]
fn a_changed_command_is_a_different_plan() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("approved-imports.toml");
remember(&store, &plan(&["server", "--stdio"])).unwrap();
assert!(!is_approved(Some(&store), &plan(&["curl", "evil.sh"])));
}
#[test]
fn a_changed_env_set_is_a_different_plan() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("approved-imports.toml");
let approved = plan(&["server"]);
remember(&store, &approved).unwrap();
let mut widened = approved.clone();
widened.env_keys.push("AWS_SECRET_ACCESS_KEY".to_string());
assert!(!is_approved(Some(&store), &widened));
}
#[test]
fn the_store_never_holds_env_values() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("approved-imports.toml");
remember(&store, &plan(&["server"])).unwrap();
let written = std::fs::read_to_string(&store).unwrap();
assert!(written.contains("API_TOKEN"), "keys are the record");
assert!(
!written.contains("secret-value"),
"a trust record must not become a place secrets live"
);
}
#[test]
fn credential_shaped_variables_are_singled_out() {
let mut env = BTreeMap::new();
env.insert("GITHUB_TOKEN".to_string(), "x".to_string());
env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "x".to_string());
env.insert("REGION".to_string(), "x".to_string());
let plan = SpawnPlan::new(Path::new("/repo/.mcp.json"), "local", &[], None, &env);
let flagged: Vec<&str> = plan
.credential_env_keys()
.into_iter()
.map(String::as_str)
.collect();
assert_eq!(flagged, vec!["AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN"]);
}
#[test]
fn a_corrupt_store_asks_again_rather_than_trusting() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("approved-imports.toml");
std::fs::write(&store, "this is not toml {{{").unwrap();
assert!(!is_approved(Some(&store), &plan(&["server"])));
}
#[test]
fn without_a_config_location_nothing_is_pre_approved() {
assert!(!is_approved(None, &plan(&["server"])));
}
#[test]
fn trust_import_skips_the_check_entirely() {
assert!(matches!(
authorize(&plan(&["server"]), None, true, false),
Decision::Approved
));
}
#[test]
fn a_non_interactive_session_refuses_with_guidance() {
match authorize(&plan(&["server"]), None, false, false) {
Decision::Refused(message) => {
assert!(message.contains("--trust-import"));
assert!(message.contains("local"));
}
Decision::Approved => panic!("an unapproved entry must not spawn unattended"),
}
}
}