use std::path::{Path, PathBuf};
use tsafe_core::contracts::{self, AuthorityContract};
use tsafe_core::profile;
use crate::errors::{McpError, McpErrorKind};
pub const EPHEMERAL_PROFILE_NAME: &str = "__tsafe_ephemeral__";
#[derive(Debug, Clone, Default)]
pub struct SessionArgs {
pub profile: Option<String>,
pub allowed_keys: Vec<String>,
pub denied_keys: Vec<String>,
pub contract: Option<String>,
pub workdir: Option<PathBuf>,
pub allow_reveal: bool,
pub audit_source: Option<String>,
pub require_agent: bool,
pub ephemeral_profile: bool,
}
impl SessionArgs {
pub fn parse(argv: &[String]) -> Result<Self, McpError> {
let mut out = SessionArgs {
require_agent: env_truthy("TSAFE_MCP_REQUIRE_AGENT", true),
ephemeral_profile: env_truthy("TSAFE_MCP_EPHEMERAL_PROFILE", false),
..SessionArgs::default()
};
if out.ephemeral_profile {
out.profile = Some(EPHEMERAL_PROFILE_NAME.to_string());
}
if let Ok(v) = std::env::var("TSAFE_MCP_PROFILE") {
if !v.is_empty() {
out.profile = Some(v);
}
}
if let Ok(v) = std::env::var("TSAFE_MCP_ALLOWED_KEYS") {
out.allowed_keys = split_csv(&v);
}
if let Ok(v) = std::env::var("TSAFE_MCP_DENIED_KEYS") {
out.denied_keys = split_csv(&v);
}
if let Ok(v) = std::env::var("TSAFE_MCP_CONTRACT") {
if !v.is_empty() {
out.contract = Some(v);
}
}
if let Ok(v) = std::env::var("TSAFE_MCP_WORKDIR") {
if !v.is_empty() {
out.workdir = Some(PathBuf::from(v));
}
}
if env_truthy("TSAFE_MCP_ALLOW_REVEAL", false) {
out.allow_reveal = true;
}
if let Ok(v) = std::env::var("TSAFE_MCP_AUDIT_SOURCE") {
if !v.is_empty() {
out.audit_source = Some(v);
}
}
let mut i = 0;
while i < argv.len() {
match argv[i].as_str() {
"--profile" => {
i += 1;
out.profile = Some(require_value(argv, i, "--profile")?);
}
"--allowed-keys" => {
i += 1;
out.allowed_keys = split_csv(&require_value(argv, i, "--allowed-keys")?);
}
"--denied-keys" => {
i += 1;
out.denied_keys = split_csv(&require_value(argv, i, "--denied-keys")?);
}
"--contract" => {
i += 1;
out.contract = Some(require_value(argv, i, "--contract")?);
}
"--workdir" => {
i += 1;
out.workdir = Some(PathBuf::from(require_value(argv, i, "--workdir")?));
}
"--allow-reveal" => out.allow_reveal = true,
"--audit-source" => {
i += 1;
out.audit_source = Some(require_value(argv, i, "--audit-source")?);
}
"--no-require-agent" => out.require_agent = false,
other => {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
format!("unknown serve flag: '{other}'"),
));
}
}
i += 1;
}
Ok(out)
}
}
fn require_value(argv: &[String], i: usize, flag: &str) -> Result<String, McpError> {
argv.get(i).cloned().ok_or_else(|| {
McpError::new(
McpErrorKind::InvalidRequest,
format!("{flag} requires a value"),
)
})
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn env_truthy(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
}
Err(_) => default,
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub profile: String,
pub allowed_globs: Vec<String>,
pub denied_globs: Vec<String>,
pub contract: Option<AuthorityContract>,
pub workdir: Option<PathBuf>,
pub allow_reveal: bool,
pub audit_source: String,
pub pid: u32,
pub require_agent: bool,
pub vault_path: PathBuf,
}
impl Session {
pub fn from_cli_args(args: &SessionArgs) -> Result<Self, McpError> {
let profile = args.profile.clone().unwrap_or_default();
let profile = profile.trim().to_string();
if profile.is_empty() {
return Err(McpError::new(
McpErrorKind::ProfileNotFound,
"--profile <name> is required",
));
}
let vault_path = profile::vault_path(&profile);
if !args.ephemeral_profile && !vault_path.exists() {
return Err(McpError::new(
McpErrorKind::ProfileNotFound,
format!("Profile '{profile}' not found at {}", vault_path.display()),
));
}
let workdir = match args.workdir.as_ref() {
Some(path) => Some(resolve_workdir(path)?),
None => None,
};
let contract = if let Some(name) = args.contract.as_deref() {
Some(load_contract(&profile, name, workdir.as_deref())?)
} else {
None
};
if contract
.as_ref()
.is_some_and(|contract| contract.allow_all_secrets)
{
return Err(McpError::new(
McpErrorKind::InvalidRequest,
"bound MCP contracts must set allowed_secrets explicitly; use allowed_secrets: [] for no-secret diagnostics",
));
}
let contract_supplies_scope = contract
.as_ref()
.map(|c| !c.allowed_secrets.is_empty())
.unwrap_or(false);
let bound_no_secret_contract = contract.is_some() && workdir.is_some();
if args.allowed_keys.is_empty() && !contract_supplies_scope && !bound_no_secret_contract {
return Err(McpError::new(
McpErrorKind::InvalidRequest,
"no scope: --allowed-keys, scoped --contract, or bound --contract + --workdir required",
));
}
let pid = std::process::id();
let audit_source = args
.audit_source
.clone()
.unwrap_or_else(|| format!("mcp:unknown:{pid}"));
Ok(Session {
profile,
allowed_globs: args.allowed_keys.clone(),
denied_globs: args.denied_keys.clone(),
contract,
workdir,
allow_reveal: args.allow_reveal,
audit_source,
pid,
require_agent: args.require_agent,
vault_path,
})
}
pub fn is_in_scope(&self, key: &str) -> bool {
let allowed_by_globs = if self.allowed_globs.is_empty() {
self.contract
.as_ref()
.is_some_and(|contract| !contract.allowed_secrets.is_empty())
} else {
self.allowed_globs.iter().any(|pat| glob_match(pat, key))
};
if !allowed_by_globs {
return false;
}
if self.denied_globs.iter().any(|pat| glob_match(pat, key)) {
return false;
}
if let Some(contract) = &self.contract {
if !contract.allowed_secrets.is_empty()
&& !contract.allowed_secrets.iter().any(|s| s == key)
{
return false;
}
}
true
}
pub fn is_bound_contract_mode(&self) -> bool {
self.contract.is_some() && self.workdir.is_some()
}
}
fn resolve_workdir(path: &Path) -> Result<PathBuf, McpError> {
if path.as_os_str().is_empty() {
return Err(
McpError::new(McpErrorKind::BadWorkdir, "--workdir is blank").with_resolution(
"Bad workdir",
"--workdir must point to an existing directory",
vec!["Pass an absolute project directory path".to_string()],
),
);
}
let resolved = path.canonicalize().map_err(|e| {
McpError::new(McpErrorKind::BadWorkdir, format!("{}: {e}", path.display())).with_resolution(
"Bad workdir",
format!("{} could not be resolved: {e}", path.display()),
vec!["Create the directory or pass the intended repository root".to_string()],
)
})?;
if !resolved.is_dir() {
return Err(McpError::new(
McpErrorKind::BadWorkdir,
format!("{} is not a directory", resolved.display()),
)
.with_resolution(
"Bad workdir",
format!("{} is not a directory", resolved.display()),
vec!["Pass a repository directory, not a file".to_string()],
));
}
Ok(resolved)
}
fn load_contract(
_profile: &str,
name: &str,
workdir: Option<&Path>,
) -> Result<AuthorityContract, McpError> {
let cwd = workdir
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let manifest = contracts::find_contracts_manifest(&cwd).ok_or_else(|| {
McpError::new(
McpErrorKind::ContractMissing,
format!(
"contract '{name}' requested but no contracts manifest found from cwd {}",
cwd.display()
),
)
.with_resolution(
"Missing contract",
format!("No .tsafe.yml or .tsafe.json found from {}", cwd.display()),
vec![
"Create .tsafe.yml in the bound workdir".to_string(),
"Pass --workdir pointing at the repository containing the contract".to_string(),
],
)
})?;
contracts::load_contract(&manifest, name).map_err(|e| {
McpError::new(
McpErrorKind::ContractMissing,
format!("contract '{name}' could not be loaded: {e}"),
)
.with_resolution(
"Missing contract",
format!(
"Contract '{name}' could not be loaded from {}: {e}",
manifest.display()
),
vec![
"Check the contract name".to_string(),
"Run tsafe exec --contract <name> --plan from the same workdir".to_string(),
],
)
})
}
pub(crate) fn glob_match(pattern: &str, candidate: &str) -> bool {
glob_match_impl(pattern.as_bytes(), candidate.as_bytes())
}
fn glob_match_impl(pat: &[u8], s: &[u8]) -> bool {
let (mut pi, mut si) = (0usize, 0usize);
let (mut star_pi, mut star_si) = (None, 0usize);
while si < s.len() {
if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < pat.len() && pat[pi] == b'*' {
star_pi = Some(pi);
star_si = si;
pi += 1;
} else if let Some(sp) = star_pi {
pi = sp + 1;
star_si += 1;
si = star_si;
} else {
return false;
}
}
while pi < pat.len() && pat[pi] == b'*' {
pi += 1;
}
pi == pat.len()
}
pub(crate) fn looks_like_glob(s: &str) -> bool {
s.chars().any(|c| matches!(c, '*' | '?' | '[' | ']'))
}
#[cfg(test)]
mod tests {
use super::*;
fn args_with_profile_and_keys(profile: &str, keys: &[&str]) -> SessionArgs {
SessionArgs {
profile: Some(profile.to_string()),
allowed_keys: keys.iter().map(|s| s.to_string()).collect(),
require_agent: false,
..SessionArgs::default()
}
}
#[test]
fn parse_recognises_flags() {
let argv = vec![
"--profile".to_string(),
"demo".to_string(),
"--allowed-keys".to_string(),
"demo/*,foo/bar".to_string(),
"--allow-reveal".to_string(),
"--audit-source".to_string(),
"mcp:claude:1234".to_string(),
];
let parsed = SessionArgs::parse(&argv).unwrap();
assert_eq!(parsed.profile.as_deref(), Some("demo"));
assert_eq!(parsed.allowed_keys, vec!["demo/*", "foo/bar"]);
assert!(parsed.allow_reveal);
assert_eq!(parsed.audit_source.as_deref(), Some("mcp:claude:1234"));
}
#[test]
fn from_cli_args_rejects_missing_profile() {
let args = SessionArgs::default();
let err = Session::from_cli_args(&args).unwrap_err();
assert_eq!(err.kind, McpErrorKind::ProfileNotFound);
}
#[test]
fn from_cli_args_rejects_blank_scope() {
let args = SessionArgs {
profile: Some("test".to_string()),
allowed_keys: vec![],
require_agent: false,
..SessionArgs::default()
};
let err = Session::from_cli_args(&args).unwrap_err();
assert!(matches!(
err.kind,
McpErrorKind::ProfileNotFound | McpErrorKind::InvalidRequest
));
}
#[test]
fn glob_match_basic_cases() {
assert!(glob_match("demo/*", "demo/foo"));
assert!(glob_match("demo/*", "demo/sub/bar"));
assert!(!glob_match("demo/*", "other/foo"));
assert!(glob_match("*", "anything"));
assert!(glob_match("?bc", "abc"));
assert!(!glob_match("?bc", "abcd"));
assert!(glob_match("DB_*", "DB_HOST"));
assert!(!glob_match("DB_*", "API_KEY"));
}
#[test]
fn is_in_scope_intersects_allow_and_deny() {
let s = Session {
profile: "demo".into(),
allowed_globs: vec!["demo/*".into()],
denied_globs: vec!["demo/secret".into()],
contract: None,
workdir: None,
allow_reveal: false,
audit_source: "mcp:unknown:1".into(),
pid: 1,
require_agent: false,
vault_path: std::path::PathBuf::from("nonexistent"),
};
assert!(s.is_in_scope("demo/foo"));
assert!(!s.is_in_scope("demo/secret"));
assert!(!s.is_in_scope("other/foo"));
}
#[test]
fn looks_like_glob_detects_meta_chars() {
assert!(looks_like_glob("demo/*"));
assert!(looks_like_glob("foo?"));
assert!(looks_like_glob("a[bc]"));
assert!(!looks_like_glob("demo/foo"));
assert!(!looks_like_glob("DB_HOST"));
}
#[test]
fn helper_builds_args_with_keys() {
let a = args_with_profile_and_keys("p", &["k1", "k2"]);
assert_eq!(a.profile.as_deref(), Some("p"));
assert_eq!(a.allowed_keys, vec!["k1", "k2"]);
assert!(!a.require_agent);
}
#[test]
fn parse_no_require_agent_flag_disables_require_agent() {
temp_env::with_var("TSAFE_MCP_REQUIRE_AGENT", Some("1"), || {
let argv = vec![
"--profile".to_string(),
"demo".to_string(),
"--allowed-keys".to_string(),
"demo/*".to_string(),
"--no-require-agent".to_string(),
];
let parsed = SessionArgs::parse(&argv).unwrap();
assert!(!parsed.require_agent, "--no-require-agent must disable");
});
}
#[test]
fn parse_unknown_flag_returns_invalid_request() {
let argv = vec!["--no-such-flag".to_string()];
let err = SessionArgs::parse(&argv).expect_err("unknown flag must error");
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
assert!(err.message.contains("unknown serve flag"));
}
#[test]
fn parse_flag_missing_value_returns_invalid_request() {
let argv = vec!["--profile".to_string()];
let err = SessionArgs::parse(&argv).expect_err("missing value must error");
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
assert!(err.message.contains("--profile"));
}
#[test]
fn audit_source_default_when_unspecified() {
let pid = std::process::id();
let label = format!("mcp:unknown:{pid}");
assert!(label.starts_with("mcp:unknown:"));
assert!(label.ends_with(&pid.to_string()));
}
#[test]
fn is_in_scope_uses_contract_when_globs_empty() {
use tsafe_core::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};
use tsafe_core::rbac::RbacProfile;
let contract = AuthorityContract {
name: "deploy".to_string(),
profile: None,
namespace: None,
access_profile: RbacProfile::ReadWrite,
allow_all_secrets: false,
allowed_secrets: vec!["demo/foo".to_string(), "demo/bar".to_string()],
required_secrets: vec![],
allowed_targets: vec![],
trust: AuthorityTrust::Standard,
network: AuthorityNetworkPolicy::Inherit,
};
let s = Session {
profile: "demo".into(),
allowed_globs: vec![],
denied_globs: vec![],
contract: Some(contract),
workdir: None,
allow_reveal: false,
audit_source: "mcp:test:1".into(),
pid: 1,
require_agent: false,
vault_path: std::path::PathBuf::from("nonexistent"),
};
assert!(s.is_in_scope("demo/foo"));
assert!(s.is_in_scope("demo/bar"));
assert!(!s.is_in_scope("demo/secret"));
assert!(!s.is_in_scope("other/anything"));
}
#[test]
fn glob_match_empty_pattern_only_matches_empty_input() {
assert!(glob_match("", ""));
assert!(!glob_match("", "anything"));
}
#[test]
fn ephemeral_env_sets_flag_and_default_profile() {
temp_env::with_vars(
[
("TSAFE_MCP_EPHEMERAL_PROFILE", Some("1")),
("TSAFE_MCP_PROFILE", None),
],
|| {
let parsed = SessionArgs::parse(&[]).unwrap();
assert!(parsed.ephemeral_profile);
assert_eq!(parsed.profile.as_deref(), Some(EPHEMERAL_PROFILE_NAME));
},
);
}
#[test]
fn ephemeral_env_respects_explicit_profile() {
temp_env::with_var("TSAFE_MCP_EPHEMERAL_PROFILE", Some("true"), || {
let argv = vec!["--profile".to_string(), "sandbox".to_string()];
let parsed = SessionArgs::parse(&argv).unwrap();
assert!(parsed.ephemeral_profile);
assert_eq!(parsed.profile.as_deref(), Some("sandbox"));
});
}
#[test]
fn ephemeral_session_starts_without_vault_file() {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_vars(
[
(
"TSAFE_VAULT_DIR",
Some(vault_dir.to_string_lossy().to_string()),
),
("TSAFE_AGENT_SOCK", None),
],
|| {
let args = SessionArgs {
profile: Some(EPHEMERAL_PROFILE_NAME.to_string()),
allowed_keys: vec!["demo/*".to_string()],
require_agent: false,
ephemeral_profile: true,
..SessionArgs::default()
};
let session = Session::from_cli_args(&args)
.expect("ephemeral session must start without a vault on disk");
assert_eq!(session.profile, EPHEMERAL_PROFILE_NAME);
assert!(
!session.vault_path.exists(),
"no vault file should have been created"
);
},
);
}
#[test]
fn non_ephemeral_missing_vault_still_fails_closed() {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vault_dir.to_string_lossy().to_string()),
|| {
let args = SessionArgs {
profile: Some("no_such_profile".to_string()),
allowed_keys: vec!["demo/*".to_string()],
require_agent: false,
ephemeral_profile: false,
..SessionArgs::default()
};
let err = Session::from_cli_args(&args).unwrap_err();
assert_eq!(err.kind, McpErrorKind::ProfileNotFound);
},
);
}
#[test]
fn ephemeral_still_refuses_blank_scope() {
let tmp = tempfile::tempdir().unwrap();
let vault_dir = tmp.path().join("vaults");
std::fs::create_dir_all(&vault_dir).unwrap();
temp_env::with_var(
"TSAFE_VAULT_DIR",
Some(vault_dir.to_string_lossy().to_string()),
|| {
let args = SessionArgs {
profile: Some(EPHEMERAL_PROFILE_NAME.to_string()),
allowed_keys: vec![],
require_agent: false,
ephemeral_profile: true,
..SessionArgs::default()
};
let err = Session::from_cli_args(&args).unwrap_err();
assert_eq!(err.kind, McpErrorKind::InvalidRequest);
},
);
}
}