Skip to main content

construct/tools/
codex_cli.rs

1use super::traits::{Tool, ToolResult};
2use crate::config::CodexCliConfig;
3use crate::security::SecurityPolicy;
4use crate::security::policy::ToolOperation;
5use async_trait::async_trait;
6use serde_json::json;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::process::Command;
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 -q`).
17///
18/// This creates a two-tier agent architecture: Construct 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 -q). 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 limit check
64        if self.security.is_rate_limited() {
65            return Ok(ToolResult {
66                success: false,
67                output: String::new(),
68                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
69            });
70        }
71
72        // Enforce act policy
73        if let Err(error) = self
74            .security
75            .enforce_tool_operation(ToolOperation::Act, "codex_cli")
76        {
77            return Ok(ToolResult {
78                success: false,
79                output: String::new(),
80                error: Some(error),
81            });
82        }
83
84        // Extract prompt (required)
85        let prompt = args
86            .get("prompt")
87            .and_then(|v| v.as_str())
88            .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
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        // Record action budget
140        if !self.security.record_action() {
141            return Ok(ToolResult {
142                success: false,
143                output: String::new(),
144                error: Some("Rate limit exceeded: action budget exhausted".into()),
145            });
146        }
147
148        // Build CLI command
149        let codex_bin = if cfg!(target_os = "windows") {
150            "codex.cmd"
151        } else {
152            "codex"
153        };
154        let mut cmd = Command::new(codex_bin);
155        cmd.arg("-q").arg(prompt);
156
157        // Environment: clear everything, pass only safe vars + configured passthrough.
158        cmd.env_clear();
159        for var in SAFE_ENV_VARS {
160            if let Ok(val) = std::env::var(var) {
161                cmd.env(var, val);
162            }
163        }
164        for var in &self.config.env_passthrough {
165            let trimmed = var.trim();
166            if !trimmed.is_empty() {
167                if let Ok(val) = std::env::var(trimmed) {
168                    cmd.env(trimmed, val);
169                }
170            }
171        }
172
173        cmd.current_dir(&work_dir);
174        // Execute with timeout — use kill_on_drop(true) so the child process
175        // is automatically killed when the future is dropped on timeout,
176        // preventing zombie processes.
177        let timeout = Duration::from_secs(self.config.timeout_secs);
178        cmd.kill_on_drop(true);
179
180        let result = tokio::time::timeout(timeout, cmd.output()).await;
181
182        match result {
183            Ok(Ok(output)) => {
184                let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
185                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
186
187                // Truncate to max_output_bytes with char-boundary safety
188                if stdout.len() > self.config.max_output_bytes {
189                    let mut b = self.config.max_output_bytes.min(stdout.len());
190                    while b > 0 && !stdout.is_char_boundary(b) {
191                        b -= 1;
192                    }
193                    stdout.truncate(b);
194                    stdout.push_str("\n... [output truncated]");
195                }
196
197                Ok(ToolResult {
198                    success: output.status.success(),
199                    output: stdout,
200                    error: if stderr.is_empty() {
201                        None
202                    } else {
203                        Some(stderr)
204                    },
205                })
206            }
207            Ok(Err(e)) => {
208                let err_msg = e.to_string();
209                let msg = if err_msg.contains("No such file or directory")
210                    || err_msg.contains("not found")
211                    || err_msg.contains("cannot find")
212                {
213                    "Codex CLI ('codex') not found in PATH. Install with: npm install -g @openai/codex".into()
214                } else {
215                    format!("Failed to execute codex: {e}")
216                };
217                Ok(ToolResult {
218                    success: false,
219                    output: String::new(),
220                    error: Some(msg),
221                })
222            }
223            Err(_) => {
224                // Timeout — kill_on_drop(true) ensures the child is killed
225                // when the future is dropped.
226                Ok(ToolResult {
227                    success: false,
228                    output: String::new(),
229                    error: Some(format!(
230                        "Codex CLI timed out after {}s and was killed",
231                        self.config.timeout_secs
232                    )),
233                })
234            }
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::config::CodexCliConfig;
243    use crate::security::{AutonomyLevel, SecurityPolicy};
244
245    fn test_config() -> CodexCliConfig {
246        CodexCliConfig::default()
247    }
248
249    fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
250        Arc::new(SecurityPolicy {
251            autonomy,
252            workspace_dir: std::env::temp_dir(),
253            ..SecurityPolicy::default()
254        })
255    }
256
257    #[test]
258    fn codex_cli_tool_name() {
259        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
260        assert_eq!(tool.name(), "codex_cli");
261    }
262
263    #[test]
264    fn codex_cli_tool_schema_has_prompt() {
265        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
266        let schema = tool.parameters_schema();
267        assert!(schema["properties"]["prompt"].is_object());
268        assert!(
269            schema["required"]
270                .as_array()
271                .expect("schema required should be an array")
272                .contains(&json!("prompt"))
273        );
274        assert!(schema["properties"]["working_directory"].is_object());
275    }
276
277    #[tokio::test]
278    async fn codex_cli_blocks_rate_limited() {
279        let security = Arc::new(SecurityPolicy {
280            autonomy: AutonomyLevel::Supervised,
281            max_actions_per_hour: 0,
282            workspace_dir: std::env::temp_dir(),
283            ..SecurityPolicy::default()
284        });
285        let tool = CodexCliTool::new(security, test_config());
286        let result = tool
287            .execute(json!({"prompt": "hello"}))
288            .await
289            .expect("rate-limited should return a result");
290        assert!(!result.success);
291        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
292    }
293
294    #[tokio::test]
295    async fn codex_cli_blocks_readonly() {
296        let tool = CodexCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
297        let result = tool
298            .execute(json!({"prompt": "hello"}))
299            .await
300            .expect("readonly should return a result");
301        assert!(!result.success);
302        assert!(
303            result
304                .error
305                .as_deref()
306                .unwrap_or("")
307                .contains("read-only mode")
308        );
309    }
310
311    #[tokio::test]
312    async fn codex_cli_missing_prompt_param() {
313        let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
314        let result = tool.execute(json!({})).await;
315        assert!(result.is_err());
316        assert!(result.unwrap_err().to_string().contains("prompt"));
317    }
318
319    #[tokio::test]
320    async fn codex_cli_rejects_path_outside_workspace() {
321        let tool = CodexCliTool::new(test_security(AutonomyLevel::Full), test_config());
322        let result = tool
323            .execute(json!({
324                "prompt": "hello",
325                "working_directory": "/etc"
326            }))
327            .await
328            .expect("should return a result for path validation");
329        assert!(!result.success);
330        assert!(
331            result
332                .error
333                .as_deref()
334                .unwrap_or("")
335                .contains("outside the workspace")
336        );
337    }
338
339    #[test]
340    fn codex_cli_env_passthrough_defaults() {
341        let config = CodexCliConfig::default();
342        assert!(
343            config.env_passthrough.is_empty(),
344            "env_passthrough should default to empty"
345        );
346    }
347
348    #[test]
349    fn codex_cli_default_config_values() {
350        let config = CodexCliConfig::default();
351        assert!(!config.enabled);
352        assert_eq!(config.timeout_secs, 600);
353        assert_eq!(config.max_output_bytes, 2_097_152);
354    }
355}