netsky_core/runtime/
claude.rs1use std::path::Path;
8
9use crate::agent::AgentId;
10use crate::consts::{
11 AGENTINFINITY_EFFORT, ALLOWED_TOOLS_AGENT, ALLOWED_TOOLS_AGENTINFINITY, CLAUDE,
12 CLAUDE_FLAG_ALLOWED_TOOLS, CLAUDE_FLAG_APPEND_SYSTEM_PROMPT,
13 CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS, CLAUDE_FLAG_DISALLOWED_TOOLS, CLAUDE_FLAG_EFFORT,
14 CLAUDE_FLAG_LOAD_DEV_CHANNELS, CLAUDE_FLAG_MCP_CONFIG, CLAUDE_FLAG_MODEL,
15 CLAUDE_FLAG_PERMISSION_MODE, CLAUDE_FLAG_STRICT_MCP_CONFIG, DEFAULT_EFFORT, DEFAULT_MODEL,
16 DEV_CHANNEL_AGENT, DEV_CHANNEL_IMESSAGE, DISALLOWED_TOOLS, ENV_AGENT_EFFORT_OVERRIDE,
17 ENV_AGENT_MODEL_OVERRIDE, ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, PERMISSION_MODE_BYPASS, TMUX_BIN,
18};
19
20#[derive(Debug, Clone)]
22pub struct ClaudeConfig {
23 pub model: String,
24 pub effort: String,
25}
26
27impl ClaudeConfig {
28 pub fn defaults_for(agent: AgentId) -> Self {
31 let model =
32 std::env::var(ENV_AGENT_MODEL_OVERRIDE).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
33 let effort = std::env::var(ENV_AGENT_EFFORT_OVERRIDE).unwrap_or_else(|_| {
34 if agent.is_agentinfinity() {
35 AGENTINFINITY_EFFORT.to_string()
36 } else {
37 DEFAULT_EFFORT.to_string()
38 }
39 });
40 Self { model, effort }
41 }
42}
43
44pub(crate) fn required_deps() -> Vec<&'static str> {
45 vec![CLAUDE, TMUX_BIN, NETSKY_BIN]
46}
47
48pub(super) fn build_command(
55 agent: AgentId,
56 cfg: &ClaudeConfig,
57 mcp_config: &Path,
58 startup: &str,
59) -> String {
60 let mut parts: Vec<String> = Vec::with_capacity(26);
61 parts.push(CLAUDE.to_string());
62
63 parts.push(CLAUDE_FLAG_MODEL.to_string());
65 parts.push(shell_escape(&cfg.model));
66
67 parts.push(CLAUDE_FLAG_EFFORT.to_string());
68 parts.push(shell_escape(&cfg.effort));
69
70 parts.push(CLAUDE_FLAG_MCP_CONFIG.to_string());
71 parts.push(shell_escape(&mcp_config.display().to_string()));
72
73 if matches!(agent, AgentId::Clone(_) | AgentId::Agentinfinity) {
79 parts.push(CLAUDE_FLAG_STRICT_MCP_CONFIG.to_string());
80 }
81
82 parts.push(CLAUDE_FLAG_LOAD_DEV_CHANNELS.to_string());
83 parts.push(DEV_CHANNEL_AGENT.to_string());
84 if !matches!(agent, AgentId::Clone(_)) {
85 parts.push(DEV_CHANNEL_IMESSAGE.to_string());
86 }
87
88 parts.push(CLAUDE_FLAG_ALLOWED_TOOLS.to_string());
89 let tools = if agent.is_agentinfinity() {
90 ALLOWED_TOOLS_AGENTINFINITY
91 } else {
92 ALLOWED_TOOLS_AGENT
93 };
94 parts.push(tools.to_string());
95
96 parts.push(CLAUDE_FLAG_DISALLOWED_TOOLS.to_string());
97 parts.push(DISALLOWED_TOOLS.to_string());
98
99 parts.push(CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS.to_string());
100 parts.push(CLAUDE_FLAG_PERMISSION_MODE.to_string());
101 parts.push(PERMISSION_MODE_BYPASS.to_string());
102
103 parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
110 parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
111
112 parts.push(shell_escape(startup.trim_end_matches('\n')));
114
115 parts.join(" ")
116}
117
118pub fn shell_escape(s: &str) -> String {
122 let mut out = String::with_capacity(s.len() + 2);
123 out.push('\'');
124 for c in s.chars() {
125 if c == '\'' {
126 out.push_str("'\\''");
127 } else {
128 out.push(c);
129 }
130 }
131 out.push('\'');
132 out
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn shell_escape_simple() {
141 assert_eq!(shell_escape("foo"), "'foo'");
142 assert_eq!(shell_escape("opus[1m]"), "'opus[1m]'");
143 }
144
145 #[test]
146 fn shell_escape_with_single_quote() {
147 assert_eq!(shell_escape("it's"), "'it'\\''s'");
148 }
149
150 #[test]
151 fn cmd_for_clone_is_strict_and_omits_imessage_channel() {
152 let cfg = ClaudeConfig {
153 model: "opus[1m]".to_string(),
154 effort: "high".to_string(),
155 };
156 let cmd = build_command(
157 AgentId::Clone(2),
158 &cfg,
159 Path::new("/tmp/mcp-config.json"),
160 "/up",
161 );
162 assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
163 assert!(cmd.contains(DEV_CHANNEL_AGENT));
164 assert!(!cmd.contains(DEV_CHANNEL_IMESSAGE));
165 assert!(cmd.contains("'opus[1m]'"));
166 }
167
168 #[test]
169 fn cmd_for_agent0_is_lax_and_includes_imessage_channel() {
170 let cfg = ClaudeConfig::defaults_for(AgentId::Agent0);
171 let cmd = build_command(
172 AgentId::Agent0,
173 &cfg,
174 Path::new("/tmp/mcp-config.json"),
175 "/up",
176 );
177 assert!(!cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
178 assert!(cmd.contains(DEV_CHANNEL_AGENT));
179 assert!(cmd.contains(DEV_CHANNEL_IMESSAGE));
180 assert!(cmd.contains(ALLOWED_TOOLS_AGENT));
181 }
182
183 #[test]
184 fn cmd_for_agentinfinity_uses_watchdog_toolset() {
185 let cfg = ClaudeConfig {
186 model: DEFAULT_MODEL.to_string(),
187 effort: AGENTINFINITY_EFFORT.to_string(),
188 };
189 let cmd = build_command(
190 AgentId::Agentinfinity,
191 &cfg,
192 Path::new("/tmp/mcp-config.json"),
193 "startup",
194 );
195 assert!(cmd.contains(ALLOWED_TOOLS_AGENTINFINITY));
196 assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
197 }
198
199 #[test]
200 fn every_agent_disallows_the_agent_tool() {
201 let mcp = Path::new("/tmp/mcp-config.json");
202 for agent in [AgentId::Agent0, AgentId::Clone(3), AgentId::Agentinfinity] {
203 let cfg = ClaudeConfig::defaults_for(agent);
204 let cmd = build_command(agent, &cfg, mcp, "/up");
205 assert!(
206 cmd.contains(&format!(
207 "{CLAUDE_FLAG_DISALLOWED_TOOLS} {DISALLOWED_TOOLS}"
208 )),
209 "{agent} spawn cmd must disallow {DISALLOWED_TOOLS}: {cmd}"
210 );
211 }
212 }
213
214 #[test]
215 fn effort_is_shell_escaped_against_env_injection() {
216 let cfg = ClaudeConfig {
217 model: DEFAULT_MODEL.to_string(),
218 effort: "high;touch /tmp/pwned".to_string(),
219 };
220 let cmd = build_command(
221 AgentId::Agent0,
222 &cfg,
223 Path::new("/tmp/mcp-config.json"),
224 "/up",
225 );
226 assert!(
227 cmd.contains("'high;touch /tmp/pwned'"),
228 "effort not shell-escaped: {cmd}"
229 );
230 assert!(
231 !cmd.contains(" high;touch "),
232 "effort leaked unescaped: {cmd}"
233 );
234 }
235}