use anyhow::{bail, Result};
use chrono::Utc;
use colored::Colorize;
use serde::Serialize;
use tsafe_core::authority::{
AuthorityDenyCode, AuthorityMetadata, AuthorityMode, AuthorityRefusal,
};
#[cfg(feature = "biometric")]
use tsafe_core::keyring_store;
use tsafe_core::{audit::AuditLog, crypto, env as tsenv, profile, snapshot, vault::VaultFile};
use crate::cmd_audit_cmd::compute_chain_coverage;
use std::fmt::Write as _;
struct DoctorEnvVar {
name: &'static str,
description: &'static str,
warn_if_set: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum DoctorCheckStatus {
Ok,
Info,
Note,
Warning,
Critical,
}
impl DoctorCheckStatus {
fn exit_code(self) -> i32 {
match self {
Self::Ok | Self::Info | Self::Note => 0,
Self::Warning => 1,
Self::Critical => 2,
}
}
fn icon(self) -> colored::ColoredString {
match self {
Self::Ok => "✓".green(),
Self::Info => "i".blue(),
Self::Note => "!".yellow(),
Self::Warning => "!".yellow().bold(),
Self::Critical => "✗".red().bold(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum DoctorOverallStatus {
Healthy,
Warning,
Critical,
}
#[derive(Debug, Serialize)]
struct DoctorCheck {
code: String,
status: DoctorCheckStatus,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Debug, Serialize)]
struct DoctorSummary {
check_count: usize,
ok_count: usize,
info_count: usize,
note_count: usize,
warning_count: usize,
critical_count: usize,
secret_count: usize,
}
#[derive(Debug, Serialize)]
struct DoctorReport {
profile: String,
status: DoctorOverallStatus,
exit_code: i32,
summary: DoctorSummary,
#[serde(skip_serializing_if = "Option::is_none")]
chain_coverage_pct: Option<u8>,
checks: Vec<DoctorCheck>,
}
pub(crate) fn cmd_doctor(profile: &str, json: bool) -> Result<()> {
profile::validate_profile_name(profile)?;
let report = build_report(profile);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
render_human(&report);
}
if report.exit_code != 0 {
std::process::exit(report.exit_code);
}
Ok(())
}
#[cfg(feature = "mcp")]
pub(crate) fn cmd_mcp_doctor(
profile: &str,
code: &str,
contract: &str,
workdir: &str,
receipt_id: Option<&str>,
json: bool,
) -> Result<()> {
profile::validate_profile_name(profile)?;
let code = parse_mcp_doctor_denial_code(code)?;
let refusal = build_mcp_doctor_remediation(profile, code, contract, workdir, receipt_id);
if json {
println!("{}", serde_json::to_string_pretty(&refusal)?);
} else {
print!("{}", render_mcp_doctor_human(&refusal));
}
Ok(())
}
fn build_report(profile: &str) -> DoctorReport {
let vault_path = profile::vault_path(profile);
let mut checks = Vec::new();
let mut secret_count = 0usize;
if vault_path.exists() {
checks.push(DoctorCheck {
code: "vault.file".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("vault: {}", vault_path.display()),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.file".to_string(),
status: DoctorCheckStatus::Critical,
message: format!("vault not found for profile '{profile}'"),
detail: Some(format!("run: tsafe --profile {profile} init")),
});
}
checks.push(quick_unlock_check(profile));
match snapshot::list(profile) {
Ok(snaps) if snaps.is_empty() => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Info,
message: "snapshots: none (created on next write)".to_string(),
detail: None,
}),
Ok(snaps) => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("snapshots: {}", snaps.len()),
detail: None,
}),
Err(_) => checks.push(DoctorCheck {
code: "snapshots".to_string(),
status: DoctorCheckStatus::Critical,
message: "snapshots: could not list".to_string(),
detail: None,
}),
}
for var in doctor_env_vars() {
let (status, message) = match std::env::var(var.name) {
Ok(v) if v.is_empty() => (
DoctorCheckStatus::Note,
format!("{}: set but empty ({})", var.name, var.description),
),
Ok(_) if var.warn_if_set => (
DoctorCheckStatus::Warning,
format!(
"{}: REMOVE after CI — never set in production ({})",
var.name, var.description
),
),
Ok(_) => (
DoctorCheckStatus::Ok,
format!("{}: set ({})", var.name, var.description),
),
Err(_) => (
DoctorCheckStatus::Info,
format!("{}: not set ({})", var.name, var.description),
),
};
checks.push(DoctorCheck {
code: format!("env.{}", var.name),
status,
message,
detail: None,
});
}
if vault_path.exists() {
match std::fs::read_to_string(&vault_path) {
Ok(json) => match serde_json::from_str::<VaultFile>(&json) {
Ok(vf) => {
secret_count = vf.secrets.len();
checks.extend(vault_checks(profile, &vf));
}
Err(err) => checks.push(DoctorCheck {
code: "vault.parse".to_string(),
status: DoctorCheckStatus::Critical,
message: "vault metadata: could not parse".to_string(),
detail: Some(err.to_string()),
}),
},
Err(err) => checks.push(DoctorCheck {
code: "vault.read".to_string(),
status: DoctorCheckStatus::Critical,
message: "vault metadata: could not read".to_string(),
detail: Some(err.to_string()),
}),
}
}
let audit_log_path = profile::audit_log_path(profile);
let cov = compute_chain_coverage(&audit_log_path);
let chain_check = match cov.coverage_pct {
None => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Info,
message: "audit chain: no audit log yet".to_string(),
detail: None,
},
Some(0) | Some(_) if cov.chained == 0 => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Note,
message: format!(
"audit chain: 0% coverage ({} entries, all pre-C8 — no HMAC chain introduced yet)",
cov.total
),
detail: Some(
"Entries written before the HMAC chain upgrade (C8) cannot be retroactively chained. \
Coverage will grow as new entries are written."
.to_string(),
),
},
Some(pct) if pct < 80 => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"audit chain: {pct}% coverage — below 80% threshold ({} of {} entries chained)",
cov.chained, cov.total
),
detail: Some(
"Run `tsafe audit verify` for details. Low coverage may indicate entries \
written before the HMAC chain was introduced (pre-C8) or log rotation."
.to_string(),
),
},
Some(pct) => DoctorCheck {
code: "audit.chain".to_string(),
status: DoctorCheckStatus::Ok,
message: format!(
"audit chain: {pct}% coverage ({} of {} entries chained)",
cov.chained, cov.total
),
detail: None,
},
};
checks.push(chain_check);
finalize_report(profile, checks, secret_count, cov.coverage_pct)
}
fn vault_checks(profile: &str, vf: &VaultFile) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(DoctorCheck {
code: "vault.schema".to_string(),
status: if matches!(vf.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2") {
DoctorCheckStatus::Ok
} else {
DoctorCheckStatus::Critical
},
message: format!("schema: {}", vf.schema),
detail: None,
});
checks.push(DoctorCheck {
code: "vault.cipher".to_string(),
status: if crypto::parse_cipher_kind(&vf.cipher).is_ok() {
DoctorCheckStatus::Ok
} else {
DoctorCheckStatus::Critical
},
message: format!("cipher: {}", vf.cipher),
detail: None,
});
let today = Utc::now().date_naive();
let mut keys: Vec<&String> = vf.secrets.keys().collect();
keys.sort();
let mut expired_count = 0usize;
let mut expiry_warning_count = 0usize;
for key in keys {
let entry = &vf.secrets[key];
if let Some(exp) = entry.tags.get("expires") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(exp, "%Y-%m-%d") {
let days = (date - today).num_days();
if days < 0 {
expired_count += 1;
} else if days <= 30 {
expiry_warning_count += 1;
}
}
}
}
if expired_count > 0 {
checks.push(DoctorCheck {
code: "secret.expiry_expired".to_string(),
status: DoctorCheckStatus::Critical,
message: format!("{expired_count} key(s) have expired"),
detail: None,
});
}
if expiry_warning_count > 0 {
checks.push(DoctorCheck {
code: "secret.expiry_warning".to_string(),
status: DoctorCheckStatus::Note,
message: format!("{expiry_warning_count} key(s) approaching expiry (within 30 days)"),
detail: None,
});
}
let due = tsafe_core::vault::rotation_due(vf);
if !due.is_empty() {
checks.push(DoctorCheck {
code: "secret.rotation_overdue".to_string(),
status: DoctorCheckStatus::Warning,
message: format!("{} key(s) overdue for rotation", due.len()),
detail: None,
});
}
const SUGGEST_MASTER_ROTATE_AFTER_DAYS: i64 = 180;
let audit_log = AuditLog::new(&profile::audit_log_path(profile));
match audit_log.last_successful_operation(profile, "rotate", 8000) {
Ok(Some(ts)) => {
let days = (Utc::now() - ts).num_days();
if days > SUGGEST_MASTER_ROTATE_AFTER_DAYS {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"Master password: last re-keyed {days} day(s) ago (audit) — consider tsafe rotate"
),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Ok,
message: format!("Master password: re-keyed {days} day(s) ago (audit)"),
detail: None,
});
}
}
Ok(None) => {
let age_days = (Utc::now() - vf.created_at).num_days();
if age_days > SUGGEST_MASTER_ROTATE_AFTER_DAYS {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Warning,
message: format!(
"Master password: no successful `rotate` in audit; vault is {age_days} day(s) old — consider periodic tsafe rotate"
),
detail: None,
});
} else {
checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Info,
message: format!(
"Master password: no `rotate` in audit yet (vault {age_days} day(s) old)"
),
detail: None,
});
}
}
Err(_) => checks.push(DoctorCheck {
code: "vault.master_password".to_string(),
status: DoctorCheckStatus::Info,
message: "audit log: could not read master-password rotation hint".to_string(),
detail: None,
}),
}
checks.push(DoctorCheck {
code: "vault.secret_count".to_string(),
status: DoctorCheckStatus::Info,
message: format!("{} secret(s) in vault", vf.secrets.len()),
detail: None,
});
checks.push(DoctorCheck {
code: "exec.preview".to_string(),
status: DoctorCheckStatus::Info,
message: "exec: preview injectable names with tsafe exec --dry-run".to_string(),
detail: None,
});
if cwd_has_layered_env_files() {
checks.push(DoctorCheck {
code: "exec.layered_env".to_string(),
status: DoctorCheckStatus::Info,
message:
"exec: `.env` or `.envrc` found near cwd — parent env may layer with vault; tsafe explain exec"
.to_string(),
detail: None,
});
}
if std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") {
checks.push(DoctorCheck {
code: "exec.ci".to_string(),
status: DoctorCheckStatus::Info,
message: ci_exec_hint(),
detail: None,
});
}
let risky_exec_count = vf
.secrets
.keys()
.filter(|k| tsenv::is_dangerous_injected_env_name(k))
.count();
if risky_exec_count > 0 {
checks.push(DoctorCheck {
code: "exec.risky_env_name".to_string(),
status: DoctorCheckStatus::Note,
message: format!(
"exec: {risky_exec_count} vault key(s) use high-risk env names when injected — tsafe explain exec-security"
),
detail: None,
});
}
checks
}
fn quick_unlock_check(profile: &str) -> DoctorCheck {
#[cfg(feature = "biometric")]
{
if keyring_store::has_password(profile) {
if let Some(note) = keyring_store::quick_unlock_storage_note(profile) {
return DoctorCheck {
code: "quick_unlock".to_string(),
status: if note.contains("login keychain fallback") {
DoctorCheckStatus::Note
} else {
DoctorCheckStatus::Ok
},
message: "quick unlock: enabled".to_string(),
detail: Some(note),
};
}
return DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Ok,
message: "quick unlock: enabled (OS credential store)".to_string(),
detail: None,
};
}
DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Info,
message: "quick unlock: not configured (tsafe biometric enable)".to_string(),
detail: None,
}
}
#[cfg(not(feature = "biometric"))]
{
let _ = profile;
DoctorCheck {
code: "quick_unlock".to_string(),
status: DoctorCheckStatus::Info,
message: format!("quick unlock: {}", quick_unlock_unavailable_note()),
detail: None,
}
}
}
fn finalize_report(
profile: &str,
checks: Vec<DoctorCheck>,
secret_count: usize,
chain_coverage_pct: Option<u8>,
) -> DoctorReport {
let mut ok_count = 0usize;
let mut info_count = 0usize;
let mut note_count = 0usize;
let mut warning_count = 0usize;
let mut critical_count = 0usize;
let mut exit_code = 0i32;
for check in &checks {
match check.status {
DoctorCheckStatus::Ok => ok_count += 1,
DoctorCheckStatus::Info => info_count += 1,
DoctorCheckStatus::Note => note_count += 1,
DoctorCheckStatus::Warning => warning_count += 1,
DoctorCheckStatus::Critical => critical_count += 1,
}
exit_code = exit_code.max(check.status.exit_code());
}
let status = match exit_code {
0 => DoctorOverallStatus::Healthy,
1 => DoctorOverallStatus::Warning,
_ => DoctorOverallStatus::Critical,
};
DoctorReport {
profile: profile.to_string(),
status,
exit_code,
summary: DoctorSummary {
check_count: checks.len(),
ok_count,
info_count,
note_count,
warning_count,
critical_count,
secret_count,
},
chain_coverage_pct,
checks,
}
}
fn render_human(report: &DoctorReport) {
for check in &report.checks {
println!("{} {}", check.status.icon(), check.message);
if let Some(detail) = &check.detail {
for line in detail.lines() {
println!(" {line}");
}
}
}
if report.exit_code == 0 {
println!("\n{} vault is healthy", "✓".green().bold());
} else {
let total = report.summary.warning_count + report.summary.critical_count;
let icon = if report.exit_code == 2 {
"✗".red().bold()
} else {
"!".yellow().bold()
};
eprintln!("\n{icon} {total} issue(s) found — review output above");
}
}
#[cfg(not(feature = "biometric"))]
fn quick_unlock_unavailable_note() -> &'static str {
"unavailable in this build"
}
fn doctor_env_vars() -> Vec<DoctorEnvVar> {
#[allow(unused_mut)]
let mut vars = vec![
DoctorEnvVar {
name: "TSAFE_PROFILE",
description: "active profile override",
warn_if_set: false,
},
DoctorEnvVar {
name: "TSAFE_PASSWORD",
description: "CI password bypass — REMOVE after testing",
warn_if_set: true,
},
DoctorEnvVar {
name: "TSAFE_NEW_MASTER_PASSWORD",
description: "new master password for `tsafe rotate` automation — REMOVE after use",
warn_if_set: true,
},
DoctorEnvVar {
name: "TSAFE_VAULT_DIR",
description: "custom vault directory",
warn_if_set: false,
},
];
#[cfg(feature = "akv-pull")]
vars.push(DoctorEnvVar {
name: "TSAFE_AKV_URL",
description: "Azure Key Vault URL",
warn_if_set: false,
});
#[cfg(feature = "cloud-pull-vault")]
vars.push(DoctorEnvVar {
name: "TSAFE_HCP_URL",
description: "HashiCorp Vault address",
warn_if_set: false,
});
#[cfg(feature = "ots-sharing")]
vars.push(DoctorEnvVar {
name: "TSAFE_OTS_BASE_URL",
description: "one-time secret (OTS) HTTPS origin for share-once",
warn_if_set: false,
});
vars
}
fn ci_exec_hint() -> String {
#[cfg(feature = "agent")]
{
"exec: CI job — use scoped job secrets / TSAFE_PASSWORD or tsafe agent unlock".to_string()
}
#[cfg(not(feature = "agent"))]
{
"exec: CI job — use scoped job secrets / TSAFE_PASSWORD".to_string()
}
}
#[allow(dead_code)]
pub(crate) fn mcp_doctor_remediation(
code: AuthorityDenyCode,
authority: AuthorityMetadata,
receipt_id: impl Into<String>,
) -> AuthorityRefusal {
let (summary, detail, next_actions) = match &code {
AuthorityDenyCode::MissingContract => (
"No bound MCP contract".to_string(),
format!(
"The bound MCP server requires contract '{}' for profile '{}' and workdir '{}', but the contract could not be found or loaded.",
authority.contract, authority.profile, authority.workdir
),
vec![
"Create or fix the named contract in the nearest .tsafe.yml or .tsafe.json manifest.".to_string(),
"Run tsafe validate from the bound workdir before retrying MCP startup.".to_string(),
],
),
AuthorityDenyCode::LockedVault => (
"Vault is locked".to_string(),
format!(
"Profile '{}' is not available to the bound MCP server because the vault cannot be unlocked noninteractively.",
authority.profile
),
vec![
format!("Run tsafe agent unlock --profile {} before starting the MCP host.", authority.profile),
"Reload the MCP host after the agent reports a usable session.".to_string(),
],
),
AuthorityDenyCode::MissingAgent => (
"Agent is unavailable".to_string(),
format!(
"The bound MCP server for profile '{}' cannot reach a tsafe-agent session.",
authority.profile
),
vec![
format!("Run tsafe agent unlock --profile {} to start an approved agent session.", authority.profile),
"Confirm TSAFE_AGENT_SOCK is visible to the MCP host process.".to_string(),
],
),
AuthorityDenyCode::MissingRequiredSecret => (
"Required secret is missing".to_string(),
format!(
"Contract '{}' declares required secrets that are absent from profile '{}'. Secret values are not included in diagnostics.",
authority.contract, authority.profile
),
vec![
"Add the missing required secret to the bound profile.".to_string(),
"Run show_exec_plan after updating the vault to confirm readiness.".to_string(),
],
),
AuthorityDenyCode::BadWorkdir => (
"Bound workdir is not usable".to_string(),
format!(
"The configured MCP workdir '{}' is missing, inaccessible, or outside the accepted boundary.",
authority.workdir
),
vec![
"Update the MCP config entry to an existing project directory.".to_string(),
"Reinstall or regenerate the bound MCP config after fixing the workdir.".to_string(),
],
),
AuthorityDenyCode::TargetDenied => (
"Target denied by bound authority".to_string(),
format!(
"Contract '{}' does not allow the requested command target. Check allowed_targets for the bound authority.",
authority.contract
),
vec![
"Ask the operator to add the intended command to allowed_targets if it is legitimate.".to_string(),
"Run show_exec_plan for an already allowed command before retrying execution.".to_string(),
],
),
AuthorityDenyCode::AuditUnavailable => (
"Audit sink unavailable".to_string(),
format!(
"The bound MCP server cannot write an audit receipt for profile '{}', so execution is degraded.",
authority.profile
),
vec![
"Restore write access to the profile audit log location.".to_string(),
"Retry only after audit receipt creation succeeds.".to_string(),
],
),
other => (
"MCP authority check failed".to_string(),
format!(
"The bound MCP doctor detected authority code {other:?} for profile '{}', contract '{}', and workdir '{}'.",
authority.profile, authority.contract, authority.workdir
),
vec!["Inspect the bound MCP configuration and retry after remediation.".to_string()],
),
};
AuthorityRefusal::new(summary, detail, next_actions, code, authority, receipt_id)
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
fn build_mcp_doctor_remediation(
profile: &str,
code: AuthorityDenyCode,
contract: &str,
workdir: &str,
receipt_id: Option<&str>,
) -> AuthorityRefusal {
let receipt_id = receipt_id
.map(str::to_string)
.unwrap_or_else(|| format!("mcp_doctor_{}", authority_deny_code_label(&code)));
let authority = AuthorityMetadata {
profile: profile.to_string(),
contract: contract.to_string(),
workdir: workdir.to_string(),
mode: AuthorityMode::BoundMcp,
};
mcp_doctor_remediation(code, authority, receipt_id)
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
fn parse_mcp_doctor_denial_code(input: &str) -> Result<AuthorityDenyCode> {
let normalized = input.trim().replace('-', "_").to_ascii_lowercase();
let code = match normalized.as_str() {
"missing_contract" => AuthorityDenyCode::MissingContract,
"locked_vault" => AuthorityDenyCode::LockedVault,
"missing_agent" => AuthorityDenyCode::MissingAgent,
"missing_required_secret" => AuthorityDenyCode::MissingRequiredSecret,
"bad_workdir" => AuthorityDenyCode::BadWorkdir,
"target_denied" => AuthorityDenyCode::TargetDenied,
"path_escape" => AuthorityDenyCode::PathEscape,
"blank_scope" => AuthorityDenyCode::BlankScope,
"profile_override" => AuthorityDenyCode::ProfileOverride,
"contract_override" => AuthorityDenyCode::ContractOverride,
"request_time_widening" => AuthorityDenyCode::RequestTimeWidening,
"network_unenforced" => AuthorityDenyCode::NetworkUnenforced,
"audit_unavailable" => AuthorityDenyCode::AuditUnavailable,
"output_cap" => AuthorityDenyCode::OutputCap,
"timeout" => AuthorityDenyCode::Timeout,
"host_schema_unstable" => AuthorityDenyCode::HostSchemaUnstable,
"config_stale" => AuthorityDenyCode::ConfigStale,
"proof_unavailable" => AuthorityDenyCode::ProofUnavailable,
"parse_error" => AuthorityDenyCode::ParseError,
"internal_error" => AuthorityDenyCode::InternalError,
_ => bail!(
"unknown MCP doctor denial code '{input}'; expected one of: {}",
MCP_DOCTOR_DENIAL_CODES.join(", ")
),
};
Ok(code)
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
fn render_mcp_doctor_human(refusal: &AuthorityRefusal) -> String {
let mut out = String::new();
let _ = writeln!(out, "summary: {}", refusal.summary);
let _ = writeln!(out, "detail: {}", refusal.detail);
let _ = writeln!(out, "code: {}", authority_deny_code_label(&refusal.code));
let _ = writeln!(out, "authority:");
let _ = writeln!(out, " profile: {}", refusal.authority.profile);
let _ = writeln!(out, " contract: {}", refusal.authority.contract);
let _ = writeln!(out, " workdir: {}", refusal.authority.workdir);
let _ = writeln!(
out,
" mode: {}",
authority_mode_label(&refusal.authority.mode)
);
let _ = writeln!(out, "receipt_id: {}", refusal.receipt_id);
let _ = writeln!(out, "next_actions:");
for action in &refusal.next_actions {
let _ = writeln!(out, " - {action}");
}
out
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
fn authority_mode_label(mode: &AuthorityMode) -> &'static str {
match mode {
AuthorityMode::BoundMcp => "bound_mcp",
}
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
fn authority_deny_code_label(code: &AuthorityDenyCode) -> &'static str {
match code {
AuthorityDenyCode::MissingContract => "missing_contract",
AuthorityDenyCode::LockedVault => "locked_vault",
AuthorityDenyCode::MissingAgent => "missing_agent",
AuthorityDenyCode::MissingRequiredSecret => "missing_required_secret",
AuthorityDenyCode::BadWorkdir => "bad_workdir",
AuthorityDenyCode::TargetDenied => "target_denied",
AuthorityDenyCode::PathEscape => "path_escape",
AuthorityDenyCode::BlankScope => "blank_scope",
AuthorityDenyCode::ProfileOverride => "profile_override",
AuthorityDenyCode::ContractOverride => "contract_override",
AuthorityDenyCode::RequestTimeWidening => "request_time_widening",
AuthorityDenyCode::NetworkUnenforced => "network_unenforced",
AuthorityDenyCode::AuditUnavailable => "audit_unavailable",
AuthorityDenyCode::OutputCap => "output_cap",
AuthorityDenyCode::Timeout => "timeout",
AuthorityDenyCode::HostSchemaUnstable => "host_schema_unstable",
AuthorityDenyCode::ConfigStale => "config_stale",
AuthorityDenyCode::ProofUnavailable => "proof_unavailable",
AuthorityDenyCode::ParseError => "parse_error",
AuthorityDenyCode::InternalError => "internal_error",
}
}
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
const MCP_DOCTOR_DENIAL_CODES: &[&str] = &[
"missing_contract",
"locked_vault",
"missing_agent",
"missing_required_secret",
"bad_workdir",
"target_denied",
"path_escape",
"blank_scope",
"profile_override",
"contract_override",
"request_time_widening",
"network_unenforced",
"audit_unavailable",
"output_cap",
"timeout",
"host_schema_unstable",
"config_stale",
"proof_unavailable",
"parse_error",
"internal_error",
];
fn cwd_has_layered_env_files() -> bool {
let Ok(mut dir) = std::env::current_dir() else {
return false;
};
for _ in 0..6 {
if dir.join(".envrc").is_file() || dir.join(".env").is_file() {
return true;
}
let Some(parent) = dir.parent() else {
break;
};
dir = parent.to_path_buf();
}
false
}
#[cfg(test)]
mod tests {
use super::{ci_exec_hint, doctor_env_vars};
use tsafe_core::authority::{
AuthorityDenyCode, AuthorityMetadata, AuthorityMode, AuthorityRefusal,
};
fn test_authority() -> AuthorityMetadata {
AuthorityMetadata {
profile: "ops".to_string(),
contract: "cordance-diagnostics".to_string(),
workdir: "C:\\Users\\0ryant\\prj\\cordance".to_string(),
mode: AuthorityMode::BoundMcp,
}
}
fn assert_refusal_shape(
refusal: AuthorityRefusal,
code: AuthorityDenyCode,
summary_fragment: &str,
detail_fragment: &str,
action_fragment: &str,
) {
assert_eq!(refusal.code, code);
assert_eq!(refusal.receipt_id, "doctor_receipt");
assert_eq!(refusal.authority, test_authority());
assert!(refusal.summary.contains(summary_fragment));
assert!(refusal.detail.contains(detail_fragment));
assert!(
refusal
.next_actions
.iter()
.any(|action| action.contains(action_fragment)),
"expected an action containing {action_fragment:?}, got {:?}",
refusal.next_actions
);
}
#[test]
fn mcp_doctor_remediation_describes_missing_contract() {
let refusal = super::mcp_doctor_remediation(
AuthorityDenyCode::MissingContract,
test_authority(),
"doctor_receipt",
);
assert_refusal_shape(
refusal,
AuthorityDenyCode::MissingContract,
"No bound MCP contract",
"cordance-diagnostics",
".tsafe.yml",
);
}
#[test]
fn mcp_doctor_remediation_describes_locked_vault() {
let refusal = super::mcp_doctor_remediation(
AuthorityDenyCode::LockedVault,
test_authority(),
"doctor_receipt",
);
assert_refusal_shape(
refusal,
AuthorityDenyCode::LockedVault,
"Vault is locked",
"ops",
"tsafe agent unlock",
);
}
#[test]
fn mcp_doctor_remediation_describes_target_denied() {
let refusal = super::mcp_doctor_remediation(
AuthorityDenyCode::TargetDenied,
test_authority(),
"doctor_receipt",
);
assert_refusal_shape(
refusal,
AuthorityDenyCode::TargetDenied,
"Target denied",
"allowed_targets",
"show_exec_plan",
);
}
#[test]
fn mcp_doctor_cli_code_parser_accepts_model_safe_spellings() {
assert_eq!(
super::parse_mcp_doctor_denial_code("missing-contract").unwrap(),
AuthorityDenyCode::MissingContract
);
assert_eq!(
super::parse_mcp_doctor_denial_code("target_denied").unwrap(),
AuthorityDenyCode::TargetDenied
);
}
#[test]
fn mcp_doctor_cli_renderer_exposes_structured_refusal_fields() {
let refusal = super::build_mcp_doctor_remediation(
"ops",
AuthorityDenyCode::MissingRequiredSecret,
"cordance-diagnostics",
"C:\\Users\\0ryant\\prj\\cordance",
None,
);
assert_eq!(refusal.receipt_id, "mcp_doctor_missing_required_secret");
let payload = serde_json::to_value(&refusal).unwrap();
assert_eq!(payload["summary"], "Required secret is missing");
assert_eq!(payload["code"], "missing_required_secret");
assert_eq!(payload["authority"]["profile"], "ops");
assert_eq!(payload["authority"]["contract"], "cordance-diagnostics");
assert_eq!(
payload["authority"]["workdir"],
"C:\\Users\\0ryant\\prj\\cordance"
);
assert_eq!(payload["authority"]["mode"], "bound_mcp");
assert!(payload["next_actions"].as_array().unwrap().len() >= 2);
let human = super::render_mcp_doctor_human(&refusal);
assert!(human.contains("summary: Required secret is missing"));
assert!(human.contains("detail: Contract 'cordance-diagnostics' declares required secrets"));
assert!(human.contains("code: missing_required_secret"));
assert!(human.contains("authority:"));
assert!(human.contains("receipt_id: mcp_doctor_missing_required_secret"));
assert!(human.contains("next_actions:"));
assert!(!human.contains("supersecret"));
assert!(!human.contains("plaintext"));
}
#[test]
fn doctor_env_vars_follow_compiled_feature_shape() {
let vars: Vec<&str> = doctor_env_vars().iter().map(|var| var.name).collect();
assert!(vars.contains(&"TSAFE_PROFILE"));
assert!(vars.contains(&"TSAFE_PASSWORD"));
assert!(vars.contains(&"TSAFE_NEW_MASTER_PASSWORD"));
assert!(vars.contains(&"TSAFE_VAULT_DIR"));
assert_eq!(vars.contains(&"TSAFE_AKV_URL"), cfg!(feature = "akv-pull"));
assert_eq!(
vars.contains(&"TSAFE_HCP_URL"),
cfg!(feature = "cloud-pull-vault")
);
assert_eq!(
vars.contains(&"TSAFE_OTS_BASE_URL"),
cfg!(feature = "ots-sharing")
);
}
#[test]
fn ci_exec_hint_follows_compiled_feature_shape() {
let hint = ci_exec_hint();
assert!(hint.contains("CI job"));
assert!(hint.contains("TSAFE_PASSWORD"));
assert_eq!(hint.contains("tsafe agent unlock"), cfg!(feature = "agent"));
}
#[cfg(not(feature = "biometric"))]
#[test]
fn quick_unlock_guidance_is_absent_when_biometric_is_not_compiled() {
assert_eq!(
super::quick_unlock_unavailable_note(),
"unavailable in this build"
);
}
}