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, ALLOWED_TOOLS_CLONE,
12 CLAUDE, 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_SETTINGS, CLAUDE_FLAG_STRICT_MCP_CONFIG,
16 DEFAULT_EFFORT, DEFAULT_MODEL, DEV_CHANNEL_AGENT, DEV_CHANNEL_IMESSAGE, DISALLOWED_TOOLS,
17 DISALLOWED_TOOLS_CLONE, ENV_AGENT_EFFORT_OVERRIDE, ENV_AGENT_MODEL_OVERRIDE,
18 ENV_NETSKY_PROMPT_FILE, NETSKY_BIN, PERMISSION_MODE_BYPASS, TMUX_BIN,
19};
20
21#[derive(Debug, Clone)]
23pub struct ClaudeConfig {
24 pub model: String,
25 pub effort: String,
26}
27
28impl ClaudeConfig {
29 pub fn defaults_for(agent: AgentId) -> Self {
32 let model =
33 std::env::var(ENV_AGENT_MODEL_OVERRIDE).unwrap_or_else(|_| DEFAULT_MODEL.to_string());
34 let effort = std::env::var(ENV_AGENT_EFFORT_OVERRIDE).unwrap_or_else(|_| {
35 if agent.is_agentinfinity() {
36 AGENTINFINITY_EFFORT.to_string()
37 } else {
38 DEFAULT_EFFORT.to_string()
39 }
40 });
41 Self { model, effort }
42 }
43}
44
45pub(crate) fn required_deps() -> Vec<&'static str> {
46 vec![CLAUDE, TMUX_BIN, NETSKY_BIN]
47}
48
49pub(super) fn build_command(
56 agent: AgentId,
57 cfg: &ClaudeConfig,
58 mcp_config: &Path,
59 claude_settings: Option<&Path>,
60 startup: &str,
61) -> String {
62 let mut parts: Vec<String> = Vec::with_capacity(28);
63 parts.push(CLAUDE.to_string());
64
65 parts.push(CLAUDE_FLAG_MODEL.to_string());
67 parts.push(shell_escape(&cfg.model));
68
69 parts.push(CLAUDE_FLAG_EFFORT.to_string());
70 parts.push(shell_escape(&cfg.effort));
71
72 parts.push(CLAUDE_FLAG_MCP_CONFIG.to_string());
73 parts.push(shell_escape(&mcp_config.display().to_string()));
74
75 if let Some(settings) = claude_settings {
76 parts.push(CLAUDE_FLAG_SETTINGS.to_string());
77 parts.push(shell_escape(&settings.display().to_string()));
78 }
79
80 if matches!(agent, AgentId::Clone(_) | AgentId::Agentinfinity) {
86 parts.push(CLAUDE_FLAG_STRICT_MCP_CONFIG.to_string());
87 }
88
89 parts.push(CLAUDE_FLAG_LOAD_DEV_CHANNELS.to_string());
90 parts.push(DEV_CHANNEL_AGENT.to_string());
91 if !matches!(agent, AgentId::Clone(_)) {
92 parts.push(DEV_CHANNEL_IMESSAGE.to_string());
93 }
94
95 parts.push(CLAUDE_FLAG_ALLOWED_TOOLS.to_string());
96 let tools = if matches!(agent, AgentId::Clone(_)) {
97 ALLOWED_TOOLS_CLONE
98 } else if agent.is_agentinfinity() {
99 ALLOWED_TOOLS_AGENTINFINITY
100 } else {
101 ALLOWED_TOOLS_AGENT
102 };
103 parts.push(tools.to_string());
104
105 parts.push(CLAUDE_FLAG_DISALLOWED_TOOLS.to_string());
106 parts.push(
107 if matches!(agent, AgentId::Clone(_)) {
108 DISALLOWED_TOOLS_CLONE
109 } else {
110 DISALLOWED_TOOLS
111 }
112 .to_string(),
113 );
114
115 parts.push(CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS.to_string());
116 parts.push(CLAUDE_FLAG_PERMISSION_MODE.to_string());
117 parts.push(PERMISSION_MODE_BYPASS.to_string());
118
119 parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
126 parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
127
128 parts.push(shell_escape(startup.trim_end_matches('\n')));
130
131 parts.join(" ")
132}
133
134pub fn shell_escape(s: &str) -> String {
138 let mut out = String::with_capacity(s.len() + 2);
139 out.push('\'');
140 for c in s.chars() {
141 if c == '\'' {
142 out.push_str("'\\''");
143 } else {
144 out.push(c);
145 }
146 }
147 out.push('\'');
148 out
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn shell_escape_simple() {
157 assert_eq!(shell_escape("foo"), "'foo'");
158 assert_eq!(shell_escape("opus[1m]"), "'opus[1m]'");
159 }
160
161 #[test]
162 fn shell_escape_with_single_quote() {
163 assert_eq!(shell_escape("it's"), "'it'\\''s'");
164 }
165
166 #[test]
167 fn cmd_for_clone_is_strict_and_omits_imessage_channel() {
168 let cfg = ClaudeConfig {
169 model: "opus[1m]".to_string(),
170 effort: "high".to_string(),
171 };
172 let cmd = build_command(
173 AgentId::Clone(2),
174 &cfg,
175 Path::new("/tmp/mcp-config.json"),
176 Some(Path::new("/tmp/clone-settings.json")),
177 "/up",
178 );
179 assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
180 assert!(cmd.contains(DEV_CHANNEL_AGENT));
181 assert!(!cmd.contains(DEV_CHANNEL_IMESSAGE));
182 assert!(cmd.contains("'opus[1m]'"));
183 assert!(cmd.contains(CLAUDE_FLAG_SETTINGS));
184 assert!(cmd.contains(ALLOWED_TOOLS_CLONE));
185 }
186
187 #[test]
188 fn cmd_for_agent0_is_lax_and_includes_imessage_channel() {
189 let cfg = ClaudeConfig::defaults_for(AgentId::Agent0);
190 let cmd = build_command(
191 AgentId::Agent0,
192 &cfg,
193 Path::new("/tmp/mcp-config.json"),
194 None,
195 "/up",
196 );
197 assert!(!cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
198 assert!(cmd.contains(DEV_CHANNEL_AGENT));
199 assert!(cmd.contains(DEV_CHANNEL_IMESSAGE));
200 assert!(cmd.contains(ALLOWED_TOOLS_AGENT));
201 assert!(!cmd.contains(CLAUDE_FLAG_SETTINGS));
202 }
203
204 #[test]
205 fn cmd_for_agentinfinity_uses_watchdog_toolset() {
206 let cfg = ClaudeConfig {
207 model: DEFAULT_MODEL.to_string(),
208 effort: AGENTINFINITY_EFFORT.to_string(),
209 };
210 let cmd = build_command(
211 AgentId::Agentinfinity,
212 &cfg,
213 Path::new("/tmp/mcp-config.json"),
214 None,
215 "startup",
216 );
217 assert!(cmd.contains(ALLOWED_TOOLS_AGENTINFINITY));
218 assert!(cmd.contains(CLAUDE_FLAG_STRICT_MCP_CONFIG));
219 }
220
221 #[test]
222 fn every_agent_disallows_the_agent_tool() {
223 let mcp = Path::new("/tmp/mcp-config.json");
224 for agent in [AgentId::Agent0, AgentId::Clone(3), AgentId::Agentinfinity] {
225 let cfg = ClaudeConfig::defaults_for(agent);
226 let settings = if matches!(agent, AgentId::Clone(_)) {
227 Some(Path::new("/tmp/clone-settings.json"))
228 } else {
229 None
230 };
231 let cmd = build_command(agent, &cfg, mcp, settings, "/up");
232 let denied = if matches!(agent, AgentId::Clone(_)) {
233 DISALLOWED_TOOLS_CLONE
234 } else {
235 DISALLOWED_TOOLS
236 };
237 assert!(
238 cmd.contains(&format!("{CLAUDE_FLAG_DISALLOWED_TOOLS} {denied}")),
239 "{agent} spawn cmd must disallow {denied}: {cmd}"
240 );
241 }
242 }
243
244 #[test]
245 fn effort_is_shell_escaped_against_env_injection() {
246 let cfg = ClaudeConfig {
247 model: DEFAULT_MODEL.to_string(),
248 effort: "high;touch /tmp/pwned".to_string(),
249 };
250 let cmd = build_command(
251 AgentId::Agent0,
252 &cfg,
253 Path::new("/tmp/mcp-config.json"),
254 None,
255 "/up",
256 );
257 assert!(
258 cmd.contains("'high;touch /tmp/pwned'"),
259 "effort not shell-escaped: {cmd}"
260 );
261 assert!(
262 !cmd.contains(" high;touch "),
263 "effort leaked unescaped: {cmd}"
264 );
265 }
266}