use std::path::Path;
use crate::agent::AgentId;
use crate::consts::{
AGENTINFINITY_EFFORT, ALLOWED_TOOLS_AGENT, ALLOWED_TOOLS_AGENTINFINITY, CLAUDE,
CLAUDE_FLAG_ALLOWED_TOOLS, CLAUDE_FLAG_APPEND_SYSTEM_PROMPT,
CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS, CLAUDE_FLAG_DISALLOWED_TOOLS, CLAUDE_FLAG_EFFORT,
CLAUDE_FLAG_LOAD_DEV_CHANNELS, CLAUDE_FLAG_MCP_CONFIG, CLAUDE_FLAG_MODEL,
CLAUDE_FLAG_PERMISSION_MODE, CLAUDE_FLAG_STRICT_MCP_CONFIG, DEFAULT_EFFORT, DEFAULT_MODEL,
DEV_CHANNEL_AGENT, DEV_CHANNEL_IMESSAGE, DISALLOWED_TOOLS, ENV_AGENT_EFFORT_OVERRIDE,
ENV_AGENT_MODEL_OVERRIDE, ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, PERMISSION_MODE_BYPASS, TMUX_BIN,
};
#[derive(Debug, Clone)]
pub struct ClaudeConfig {
pub model: String,
pub effort: String,
}
impl ClaudeConfig {
pub fn defaults_for(agent: AgentId) -> Self {
let model =
std::env::var(ENV_AGENT_MODEL_OVERRIDE).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
let effort = std::env::var(ENV_AGENT_EFFORT_OVERRIDE).unwrap_or_else(|_| {
if agent.is_agentinfinity() {
AGENTINFINITY_EFFORT.to_string()
} else {
DEFAULT_EFFORT.to_string()
}
});
Self { model, effort }
}
}
pub(crate) fn required_deps() -> Vec<&'static str> {
vec![CLAUDE, TMUX_BIN, NETSKY_BIN]
}
pub(super) fn build_command(
agent: AgentId,
cfg: &ClaudeConfig,
mcp_config: &Path,
startup: &str,
) -> String {
let mut parts: Vec<String> = Vec::with_capacity(26);
parts.push(CLAUDE.to_string());
parts.push(CLAUDE_FLAG_MODEL.to_string());
parts.push(shell_escape(&cfg.model));
parts.push(CLAUDE_FLAG_EFFORT.to_string());
parts.push(shell_escape(&cfg.effort));
parts.push(CLAUDE_FLAG_MCP_CONFIG.to_string());
parts.push(shell_escape(&mcp_config.display().to_string()));
if matches!(agent, AgentId::Clone(_) | AgentId::Agentinfinity) {
parts.push(CLAUDE_FLAG_STRICT_MCP_CONFIG.to_string());
}
parts.push(CLAUDE_FLAG_LOAD_DEV_CHANNELS.to_string());
parts.push(DEV_CHANNEL_AGENT.to_string());
if !matches!(agent, AgentId::Clone(_)) {
parts.push(DEV_CHANNEL_IMESSAGE.to_string());
}
parts.push(CLAUDE_FLAG_ALLOWED_TOOLS.to_string());
let tools = if agent.is_agentinfinity() {
ALLOWED_TOOLS_AGENTINFINITY
} else {
ALLOWED_TOOLS_AGENT
};
parts.push(tools.to_string());
parts.push(CLAUDE_FLAG_DISALLOWED_TOOLS.to_string());
parts.push(DISALLOWED_TOOLS.to_string());
parts.push(CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS.to_string());
parts.push(CLAUDE_FLAG_PERMISSION_MODE.to_string());
parts.push(PERMISSION_MODE_BYPASS.to_string());
parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
parts.push(shell_escape(startup.trim_end_matches('\n')));
parts.join(" ")
}
pub fn shell_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_escape_simple() {
assert_eq!(shell_escape("foo"), "'foo'");
assert_eq!(shell_escape("opus[1m]"), "'opus[1m]'");
}
#[test]
fn shell_escape_with_single_quote() {
assert_eq!(shell_escape("it's"), "'it'\\''s'");
}
#[test]
fn cmd_for_clone_is_strict_and_omits_imessage_channel() {
let cfg = ClaudeConfig {
model: "opus[1m]".to_string(),
effort: "high".to_string(),
};
let cmd = build_command(
AgentId::Clone(2),
&cfg,
Path::new("/tmp/mcp-config.json"),
"/up",
);
assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
assert!(cmd.contains(DEV_CHANNEL_AGENT));
assert!(!cmd.contains(DEV_CHANNEL_IMESSAGE));
assert!(cmd.contains("'opus[1m]'"));
}
#[test]
fn cmd_for_agent0_is_lax_and_includes_imessage_channel() {
let cfg = ClaudeConfig::defaults_for(AgentId::Agent0);
let cmd = build_command(
AgentId::Agent0,
&cfg,
Path::new("/tmp/mcp-config.json"),
"/up",
);
assert!(!cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
assert!(cmd.contains(DEV_CHANNEL_AGENT));
assert!(cmd.contains(DEV_CHANNEL_IMESSAGE));
assert!(cmd.contains(ALLOWED_TOOLS_AGENT));
}
#[test]
fn cmd_for_agentinfinity_uses_watchdog_toolset() {
let cfg = ClaudeConfig {
model: DEFAULT_MODEL.to_string(),
effort: AGENTINFINITY_EFFORT.to_string(),
};
let cmd = build_command(
AgentId::Agentinfinity,
&cfg,
Path::new("/tmp/mcp-config.json"),
"startup",
);
assert!(cmd.contains(ALLOWED_TOOLS_AGENTINFINITY));
assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
}
#[test]
fn every_agent_disallows_the_agent_tool() {
let mcp = Path::new("/tmp/mcp-config.json");
for agent in [AgentId::Agent0, AgentId::Clone(3), AgentId::Agentinfinity] {
let cfg = ClaudeConfig::defaults_for(agent);
let cmd = build_command(agent, &cfg, mcp, "/up");
assert!(
cmd.contains(&format!(
"{CLAUDE_FLAG_DISALLOWED_TOOLS} {DISALLOWED_TOOLS}"
)),
"{agent} spawn cmd must disallow {DISALLOWED_TOOLS}: {cmd}"
);
}
}
#[test]
fn effort_is_shell_escaped_against_env_injection() {
let cfg = ClaudeConfig {
model: DEFAULT_MODEL.to_string(),
effort: "high;touch /tmp/pwned".to_string(),
};
let cmd = build_command(
AgentId::Agent0,
&cfg,
Path::new("/tmp/mcp-config.json"),
"/up",
);
assert!(
cmd.contains("'high;touch /tmp/pwned'"),
"effort not shell-escaped: {cmd}"
);
assert!(
!cmd.contains(" high;touch "),
"effort leaked unescaped: {cmd}"
);
}
}