use crate::agent::Access;
use crate::config::Config;
use crate::graph::RouteGraph;
use crate::mcp::{McpTransport, looks_like_a_secret};
use super::Diagnostic;
pub(super) fn check_mcp_and_workspaces(config: &Config, found: &mut Vec<Diagnostic>) {
check_mcp_servers(config, found);
check_parallel_instances_are_isolated(config, found);
check_agent_work_dirs(config, found);
}
fn check_mcp_servers(config: &Config, found: &mut Vec<Diagnostic>) {
for (agent, definition) in &config.agents {
for (server, spec) in &definition.mcp {
let at = format!("agent `{agent}` MCP server `{server}`");
if !is_identifier(server) {
found.push(Diagnostic::error(format!(
"{at} is not a usable name; use letters, digits, hyphens and underscores"
)));
}
match spec.transport() {
Err(reason) => found.push(Diagnostic::error(format!("{at} {reason}"))),
Ok(McpTransport::Stdio { command: [] }) => {
found.push(Diagnostic::error(format!("{at} has an empty `command`")));
}
Ok(McpTransport::Stdio { .. }) => {}
Ok(McpTransport::Http { url }) => check_url(&at, url, found),
}
for name in spec.env.keys() {
if looks_like_a_secret(name) {
found.push(Diagnostic::error(format!(
"{at} sets `{name}` literally in `env`, and that name looks like a \
credential; move it to `env_from = [\"{name}\"]` so the value comes from \
the Tower's environment instead of this file"
)));
}
}
for name in &spec.env_from {
if name.trim().is_empty() {
found.push(Diagnostic::error(format!(
"{at} has an empty name in `env_from`"
)));
}
}
}
}
}
fn check_url(at: &str, url: &str, found: &mut Vec<Diagnostic>) {
let local = url.starts_with("http://127.0.0.1")
|| url.starts_with("http://localhost")
|| url.starts_with("http://[::1]");
if url.starts_with("https://") || local {
return;
}
if url.starts_with("http://") {
found.push(Diagnostic::warning(format!(
"{at} uses plain HTTP to a non-local address; whatever it sends, including anything \
forwarded through `env_from`, crosses the network in the clear"
)));
} else {
found.push(Diagnostic::error(format!(
"{at} has a `url` that is neither http nor https"
)));
}
}
fn check_parallel_instances_are_isolated(config: &Config, found: &mut Vec<Diagnostic>) {
let graph = RouteGraph::from_config(config);
for (name, pipeline) in config.scheduled_pipelines() {
if pipeline.workspace.is_isolated() || !pipeline.allows_overlap() {
continue;
}
let writers: Vec<String> = graph
.reachable_from([&pipeline.entry])
.into_iter()
.filter(|agent| {
config
.agents
.get(agent)
.is_some_and(|a| a.access == Access::ReadWrite)
})
.map(|agent| format!("`{agent}`"))
.collect();
if writers.is_empty() {
continue;
}
found.push(Diagnostic::warning(format!(
"pipeline `{name}` sets `overlap = \"allow\"`, reaches read-write \
agent(s) {} and uses `workspace = \"shared\"`; two instances would edit the same \
files at once. Set `workspace = \"per-itinerary\"` to give each its own worktree",
writers.join(", ")
)));
}
}
fn check_agent_work_dirs(config: &Config, found: &mut Vec<Diagnostic>) {
for (name, agent) in &config.agents {
let Some(dir) = &agent.work_dir else {
continue;
};
if dir.as_os_str().is_empty() {
found.push(Diagnostic::error(format!(
"agent `{name}` has an empty `work_dir`"
)));
}
if agent.access == Access::ReadWrite {
found.push(Diagnostic::warning(format!(
"agent `{name}` is read-write and has its own `work_dir`, which sits outside any \
per-itinerary isolation; every instance of every pipeline writes to it"
)));
}
}
}
fn is_identifier(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[cfg(test)]
mod tests {
use crate::validate::testing::{assert_mentions, errors, parse, warnings};
use crate::validate::validate;
const WRITER: &str = r#"
[agents.analyst]
runner = "claude"
description = "analyses"
prompt = "analyse"
[agents.developer]
runner = "claude"
description = "builds"
prompt = "build"
[[routes]]
from = "analyst"
to = "developer"
"#;
#[test]
fn a_literal_credential_is_refused() {
let config = parse(
r#"
[agents.kusto]
runner = "claude"
description = "queries telemetry"
prompt = "query"
entry = true
[agents.kusto.mcp.kusto]
command = ["agency", "mcp", "kusto"]
env = { KUSTO_CLUSTER = "eus2", AZURE_CLIENT_SECRET = "hunter2" }
"#,
);
let errors = errors(&config);
assert_mentions(&errors, "AZURE_CLIENT_SECRET");
assert_mentions(&errors, "env_from");
assert!(
!errors.iter().any(|m| m.contains("hunter2")),
"the diagnostic must not echo the value it is telling you to remove: {errors:?}"
);
}
#[test]
fn forwarding_a_credential_by_name_is_accepted() {
let config = parse(
r#"
[agents.kusto]
runner = "claude"
description = "queries telemetry"
prompt = "query"
entry = true
access = "read-only"
[agents.kusto.mcp.kusto]
command = ["agency", "mcp", "kusto"]
env = { KUSTO_CLUSTER = "eus2" }
env_from = ["AZURE_CLIENT_SECRET"]
"#,
);
assert_eq!(validate(&config), Vec::new());
}
#[test]
fn an_empty_command_is_refused() {
let config = parse(
r#"
[agents.kusto]
runner = "claude"
description = "queries"
prompt = "query"
entry = true
[agents.kusto.mcp.broken]
command = []
"#,
);
assert_mentions(&errors(&config), "empty `command`");
}
#[test]
fn plain_http_to_a_remote_server_warns() {
let config = parse(
r#"
[agents.kusto]
runner = "claude"
description = "queries"
prompt = "query"
entry = true
access = "read-only"
[agents.kusto.mcp.remote]
url = "http://mcp.example.com/"
"#,
);
assert_mentions(&warnings(&config), "in the clear");
}
#[test]
fn plain_http_to_loopback_is_fine() {
let config = parse(
r#"
[agents.kusto]
runner = "claude"
description = "queries"
prompt = "query"
entry = true
access = "read-only"
[agents.kusto.mcp.local]
url = "http://127.0.0.1:7878/mcp"
"#,
);
assert_eq!(validate(&config), Vec::new());
}
#[test]
fn a_shared_workspace_with_a_writer_warns_only_when_overlap_is_allowed() {
let config = parse(&format!(
r#"
[reserve]
fuel_usd = 500.0
{WRITER}
[pipelines.development]
entry = "analyst"
trigger = {{ every = "1h" }}
overlap = "allow"
"#
));
assert_mentions(
&warnings(&config),
"two instances would edit the same files",
);
}
#[test]
fn the_default_schedule_cannot_overlap_so_nothing_is_said() {
let config = parse(&format!(
r#"
[reserve]
fuel_usd = 500.0
{WRITER}
[pipelines.development]
entry = "analyst"
trigger = {{ every = "1h" }}
"#
));
assert!(
!warnings(&config)
.iter()
.any(|w| w.contains("two instances would edit the same files")),
"a skip-by-default schedule cannot overlap itself"
);
}
#[test]
fn per_itinerary_isolation_makes_parallel_instances_safe() {
let config = parse(&format!(
r#"
[reserve]
fuel_usd = 500.0
{WRITER}
[pipelines.development]
entry = "analyst"
trigger = {{ every = "1h" }}
workspace = "per-itinerary"
"#
));
assert_eq!(validate(&config), Vec::new());
}
#[test]
fn a_read_only_pipeline_needs_no_isolation() {
let config = parse(
r#"
[agents.scanner]
runner = "claude"
description = "scans"
prompt = "scan"
access = "read-only"
[pipelines.review-bot]
entry = "scanner"
"#,
);
assert_eq!(validate(&config), Vec::new());
}
#[test]
fn a_read_write_agent_with_its_own_work_dir_warns() {
let config = parse(
r#"
[agents.publisher]
runner = "claude"
description = "publishes"
prompt = "publish"
entry = true
work_dir = "../other-repo"
recovery = "manual"
[pipelines.publish]
entry = "publisher"
workspace = "per-itinerary"
"#,
);
assert_mentions(&warnings(&config), "outside any per-itinerary isolation");
}
}