Skip to main content

netsky_core/runtime/
claude.rs

1//! Claude runtime: build the `claude` CLI invocation for a spawned agent.
2//!
3//! Single code path for agent0, clones, and agentinfinity. Per-agent
4//! differences (allowed tools, MCP servers + strict mode, dev-channel
5//! flags, effort level) key off [`AgentId`].
6
7use 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/// Per-agent claude-CLI configuration.
22#[derive(Debug, Clone)]
23pub struct ClaudeConfig {
24    pub model: String,
25    pub effort: String,
26}
27
28impl ClaudeConfig {
29    /// Defaults for `agent`, honoring `AGENT_MODEL` / `AGENT_EFFORT`
30    /// env overrides and agentinfinity's lower effort level.
31    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
49/// Build the shell command string tmux will run inside the detached
50/// session. `claude --flag value --flag value "$(cat "$NETSKY_PROMPT_FILE")" '<startup>'`.
51/// The shell `$(cat ...)` expansion reads the rendered system prompt at
52/// exec time from the file the spawner wrote, avoiding the tmux
53/// ARG_MAX / "command too long" limit that would otherwise hit on the
54/// `tmux new-session -e NETSKY_PROMPT=<24KB>` path (the session-11 outage).
55pub(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    // model is single-quoted to suppress zsh glob on names like `opus[1m]`.
66    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    // Clones + agentinfinity get strict MCP — only servers in the config
81    // file load. agent0 is non-strict so user-scoped MCPs added via
82    // `claude mcp add -s user` can layer on top, but every netsky-io
83    // source (agent, imessage, email, calendar) is configured at project
84    // scope in `.mcp.json` so all three agent classes see the same baseline.
85    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    // System prompt delivered via a tempfile whose path is in $NETSKY_PROMPT_FILE.
120    // The shell tmux runs reads it at exec time via `$(cat "$NETSKY_PROMPT_FILE")`.
121    // This replaces an earlier mechanism that passed the 20KB+ prompt content
122    // directly as `-e NETSKY_PROMPT=<content>` to tmux new-session, which hit
123    // tmux's internal argv length limit ("command too long") and caused every
124    // agent0 restart to fail silently in session-11.
125    parts.push(CLAUDE_FLAG_APPEND_SYSTEM_PROMPT.to_string());
126    parts.push(format!("\"$(cat \"${ENV_NETSKY_PROMPT_FILE}\")\""));
127
128    // Startup prompt as a single positional arg.
129    parts.push(shell_escape(startup.trim_end_matches('\n')));
130
131    parts.join(" ")
132}
133
134/// POSIX-safe single-quote escape. Wraps `s` in `'...'`, escaping
135/// embedded `'` as `'\''`. Shared with any future runtime that
136/// constructs shell command strings.
137pub 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}