construct/tools/
codex_cli.rs1use 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
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16pub 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 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 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 let prompt = args
86 .get("prompt")
87 .and_then(|v| v.as_str())
88 .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
89
90 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 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 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 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 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 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 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}