Skip to main content

zeroclaw_tools/
codex_cli.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use std::time::Duration;
5use tokio::process::Command;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::policy::ToolOperation;
9use zeroclaw_config::schema::CodexCliConfig;
10
11/// Environment variables safe to pass through to the `codex` subprocess.
12const SAFE_ENV_VARS: &[&str] = &[
13    "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16/// Delegates coding tasks to the Codex CLI (`codex exec`).
17///
18/// This creates a two-tier agent architecture: ZeroClaw orchestrates high-level
19/// tasks and delegates complex coding work to Codex, which has its own
20/// agent loop with file editing and shell tools.
21///
22/// Authentication uses the `codex` binary's own session by default. No API key
23/// is needed unless `env_passthrough` includes `OPENAI_API_KEY`.
24pub struct CodexCliTool {
25    security: Arc<SecurityPolicy>,
26    config: CodexCliConfig,
27}
28
29impl CodexCliTool {
30    pub fn new(security: Arc<SecurityPolicy>, config: CodexCliConfig) -> Self {
31        Self { security, config }
32    }
33}
34
35#[async_trait]
36impl Tool for CodexCliTool {
37    fn name(&self) -> &str {
38        "codex_cli"
39    }
40
41    fn description(&self) -> &str {
42        "Delegate a coding task to Codex CLI (codex exec). Supports file editing and bash execution. Use for complex coding work that benefits from Codex's full agent loop."
43    }
44
45    fn parameters_schema(&self) -> serde_json::Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "prompt": {
50                    "type": "string",
51                    "description": "The coding task to delegate to Codex"
52                },
53                "working_directory": {
54                    "type": "string",
55                    "description": "Working directory within the workspace (must be inside workspace_dir)"
56                }
57            },
58            "required": ["prompt"]
59        })
60    }
61
62    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
63        // Rate limiting is applied by the RateLimitedTool wrapper at
64        // registration time (see zeroclaw-runtime::tools::mod).
65
66        // Enforce act policy
67        if let Err(error) = self
68            .security
69            .enforce_tool_operation(ToolOperation::Act, "codex_cli")
70        {
71            return Ok(ToolResult {
72                success: false,
73                output: String::new(),
74                error: Some(error),
75            });
76        }
77
78        // Extract prompt (required)
79        let prompt = args.get("prompt").and_then(|v| v.as_str()).ok_or_else(|| {
80            ::zeroclaw_log::record!(
81                WARN,
82                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
83                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
84                    .with_attrs(::serde_json::json!({"param": "prompt"})),
85                "codex_cli: missing prompt parameter"
86            );
87            anyhow::Error::msg("Missing 'prompt' parameter")
88        })?;
89
90        // Validate working directory — require both paths to exist (reject
91        // non-existent paths instead of falling back to the raw value, which
92        // could bypass the workspace containment check via symlinks or
93        // specially-crafted path components).
94        let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
95            let wd_path = std::path::PathBuf::from(wd);
96            let workspace = &self.security.workspace_dir;
97            let canonical_wd = match wd_path.canonicalize() {
98                Ok(p) => p,
99                Err(_) => {
100                    return Ok(ToolResult {
101                        success: false,
102                        output: String::new(),
103                        error: Some(format!(
104                            "working_directory '{}' does not exist or is not accessible",
105                            wd
106                        )),
107                    });
108                }
109            };
110            let canonical_ws = match workspace.canonicalize() {
111                Ok(p) => p,
112                Err(_) => {
113                    return Ok(ToolResult {
114                        success: false,
115                        output: String::new(),
116                        error: Some(format!(
117                            "workspace directory '{}' does not exist or is not accessible",
118                            workspace.display()
119                        )),
120                    });
121                }
122            };
123            if !canonical_wd.starts_with(&canonical_ws) {
124                return Ok(ToolResult {
125                    success: false,
126                    output: String::new(),
127                    error: Some(format!(
128                        "working_directory '{}' is outside the workspace '{}'",
129                        wd,
130                        workspace.display()
131                    )),
132                });
133            }
134            canonical_wd
135        } else {
136            self.security.workspace_dir.clone()
137        };
138
139        // Build CLI command: `codex exec [extra_args...] <prompt>`
140        let codex_bin = if cfg!(target_os = "windows") {
141            "codex.cmd"
142        } else {
143            "codex"
144        };
145        let mut cmd = Command::new(codex_bin);
146        cmd.arg("exec");
147
148        // Append user-configured extra arguments (e.g. --sandbox, --skip-git-repo-check)
149        for arg in &self.config.extra_args {
150            let trimmed = arg.trim();
151            if !trimmed.is_empty() {
152                cmd.arg(trimmed);
153            }
154        }
155
156        cmd.arg(prompt);
157
158        // Environment: clear everything, pass only safe vars + configured passthrough.
159        cmd.env_clear();
160        for var in SAFE_ENV_VARS {
161            if let Ok(val) = std::env::var(var) {
162                cmd.env(var, val);
163            }
164        }
165        for var in &self.config.env_passthrough {
166            let trimmed = var.trim();
167            if !trimmed.is_empty()
168                && let Ok(val) = std::env::var(trimmed)
169            {
170                cmd.env(trimmed, val);
171            }
172        }
173
174        cmd.current_dir(&work_dir);
175        // Execute with timeout — use kill_on_drop(true) so the child process
176        // is automatically killed when the future is dropped on timeout,
177        // preventing zombie processes.
178        let timeout = Duration::from_secs(self.config.timeout_secs);
179        cmd.kill_on_drop(true);
180
181        let result = tokio::time::timeout(timeout, cmd.output()).await;
182
183        match result {
184            Ok(Ok(output)) => {
185                let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
186                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
187
188                // Truncate to max_output_bytes with char-boundary safety
189                if stdout.len() > self.config.max_output_bytes {
190                    let mut b = self.config.max_output_bytes.min(stdout.len());
191                    while b > 0 && !stdout.is_char_boundary(b) {
192                        b -= 1;
193                    }
194                    stdout.truncate(b);
195                    stdout.push_str("\n... [output truncated]");
196                }
197
198                Ok(ToolResult {
199                    success: output.status.success(),
200                    output: stdout,
201                    error: if stderr.is_empty() {
202                        None
203                    } else {
204                        Some(stderr)
205                    },
206                })
207            }
208            Ok(Err(e)) => {
209                let err_msg = e.to_string();
210                let msg = if err_msg.contains("No such file or directory")
211                    || err_msg.contains("not found")
212                    || err_msg.contains("cannot find")
213                {
214                    "Codex CLI ('codex') not found in PATH. Install with: npm install -g @openai/codex".into()
215                } else {
216                    format!("Failed to execute codex: {e}")
217                };
218                Ok(ToolResult {
219                    success: false,
220                    output: String::new(),
221                    error: Some(msg),
222                })
223            }
224            Err(_) => {
225                // Timeout — kill_on_drop(true) ensures the child is killed
226                // when the future is dropped.
227                Ok(ToolResult {
228                    success: false,
229                    output: String::new(),
230                    error: Some(format!(
231                        "Codex CLI timed out after {}s and was killed",
232                        self.config.timeout_secs
233                    )),
234                })
235            }
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use zeroclaw_config::autonomy::AutonomyLevel;
244    use zeroclaw_config::policy::SecurityPolicy;
245    use zeroclaw_config::schema::CodexCliConfig;
246
247    fn test_config() -> CodexCliConfig {
248        CodexCliConfig::default()
249    }
250
251    fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
252        Arc::new(SecurityPolicy {
253            autonomy,
254            workspace_dir: std::env::temp_dir(),
255            ..SecurityPolicy::default()
256        })
257    }
258
259    #[test]
260    fn codex_cli_tool_name() {
261        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
262        assert_eq!(tool.name(), "codex_cli");
263    }
264
265    #[test]
266    fn codex_cli_tool_schema_has_prompt() {
267        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
268        let schema = tool.parameters_schema();
269        assert!(schema["properties"]["prompt"].is_object());
270        assert!(
271            schema["required"]
272                .as_array()
273                .expect("schema required should be an array")
274                .contains(&json!("prompt"))
275        );
276        assert!(schema["properties"]["working_directory"].is_object());
277    }
278
279    #[tokio::test]
280    async fn codex_cli_blocks_rate_limited() {
281        let security = Arc::new(SecurityPolicy {
282            autonomy: AutonomyLevel::Supervised,
283            max_actions_per_hour: 0,
284            workspace_dir: std::env::temp_dir(),
285            ..SecurityPolicy::default()
286        });
287        let tool = CodexCliTool::new(security, test_config());
288        let result = tool
289            .execute(json!({"prompt": "hello"}))
290            .await
291            .expect("rate-limited should return a result");
292        assert!(!result.success);
293        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
294    }
295
296    #[tokio::test]
297    async fn codex_cli_blocks_readonly() {
298        let tool = CodexCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
299        let result = tool
300            .execute(json!({"prompt": "hello"}))
301            .await
302            .expect("readonly should return a result");
303        assert!(!result.success);
304        assert!(
305            result
306                .error
307                .as_deref()
308                .unwrap_or("")
309                .contains("read-only mode")
310        );
311    }
312
313    #[tokio::test]
314    async fn codex_cli_missing_prompt_param() {
315        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
316        let result = tool.execute(json!({})).await;
317        assert!(result.is_err());
318        assert!(result.unwrap_err().to_string().contains("prompt"));
319    }
320
321    #[tokio::test]
322    async fn codex_cli_rejects_path_outside_workspace() {
323        let tool = CodexCliTool::new(test_security(AutonomyLevel::Full), test_config());
324        let result = tool
325            .execute(json!({
326                "prompt": "hello",
327                "working_directory": "/etc"
328            }))
329            .await
330            .expect("should return a result for path validation");
331        assert!(!result.success);
332        assert!(
333            result
334                .error
335                .as_deref()
336                .unwrap_or("")
337                .contains("outside the workspace")
338        );
339    }
340
341    #[test]
342    fn codex_cli_env_passthrough_defaults() {
343        let config = CodexCliConfig::default();
344        assert!(
345            config.env_passthrough.is_empty(),
346            "env_passthrough should default to empty"
347        );
348    }
349
350    #[test]
351    fn codex_cli_extra_args_defaults() {
352        let config = CodexCliConfig::default();
353        assert!(
354            config.extra_args.is_empty(),
355            "extra_args should default to empty"
356        );
357    }
358
359    #[test]
360    fn codex_cli_default_config_values() {
361        let config = CodexCliConfig::default();
362        assert!(!config.enabled);
363        assert_eq!(config.timeout_secs, 600);
364        assert_eq!(config.max_output_bytes, 2_097_152);
365    }
366}