1use std::collections::BTreeMap;
32use std::path::PathBuf;
33use std::time::Duration;
34
35use agentkit_core::{MetadataMap, ToolOutput, ToolResultPart};
36use agentkit_tools_core::{
37 PermissionRequest, ShellPermissionRequest, Tool, ToolAnnotations, ToolContext, ToolError,
38 ToolName, ToolRegistry, ToolRequest, ToolResult, ToolSpec,
39};
40use async_trait::async_trait;
41use serde::Deserialize;
42use serde_json::json;
43use tokio::process::Command;
44use tokio::time::timeout;
45
46pub fn registry() -> ToolRegistry {
61 ToolRegistry::new().with(ShellExecTool::default())
62}
63
64#[derive(Clone, Debug)]
100pub struct ShellExecTool {
101 spec: ToolSpec,
102}
103
104impl Default for ShellExecTool {
105 fn default() -> Self {
106 Self {
107 spec: ToolSpec {
108 name: ToolName::new("shell.exec"),
109 description: "Execute a shell command and capture stdout, stderr, and exit status."
110 .into(),
111 input_schema: json!({
112 "type": "object",
113 "properties": {
114 "executable": { "type": "string" },
115 "argv": {
116 "type": "array",
117 "items": { "type": "string" },
118 "default": []
119 },
120 "cwd": { "type": "string" },
121 "env": {
122 "type": "object",
123 "additionalProperties": { "type": "string" }
124 },
125 "timeout_ms": { "type": "integer", "minimum": 1 }
126 },
127 "required": ["executable"],
128 "additionalProperties": false
129 }),
130 annotations: ToolAnnotations {
131 destructive_hint: true,
132 needs_approval_hint: true,
133 ..ToolAnnotations::default()
134 },
135 metadata: MetadataMap::new(),
136 },
137 }
138 }
139}
140
141#[derive(Debug, Deserialize)]
142struct ShellExecInput {
143 executable: String,
144 #[serde(default)]
145 argv: Vec<String>,
146 cwd: Option<PathBuf>,
147 #[serde(default)]
148 env: BTreeMap<String, String>,
149 timeout_ms: Option<u64>,
150}
151
152#[async_trait]
153impl Tool for ShellExecTool {
154 fn spec(&self) -> &ToolSpec {
156 &self.spec
157 }
158
159 fn proposed_requests(
172 &self,
173 request: &ToolRequest,
174 ) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError> {
175 let input: ShellExecInput = parse_input(request)?;
176 Ok(vec![Box::new(ShellPermissionRequest {
177 executable: input.executable,
178 argv: input.argv,
179 cwd: input.cwd,
180 env_keys: input.env.keys().cloned().collect(),
181 metadata: request.metadata.clone(),
182 })])
183 }
184
185 async fn invoke(
210 &self,
211 request: ToolRequest,
212 ctx: &mut ToolContext<'_>,
213 ) -> Result<ToolResult, ToolError> {
214 let input: ShellExecInput = parse_input(&request)?;
215 let mut command = Command::new(&input.executable);
216 command.args(&input.argv);
217 command.kill_on_drop(true);
218 if let Some(cwd) = &input.cwd {
219 command.current_dir(cwd);
220 }
221 for (key, value) in &input.env {
222 command.env(key, value);
223 }
224
225 let duration_start = std::time::Instant::now();
226 let output_future = command.output();
227 tokio::pin!(output_future);
228
229 let output = if let Some(timeout_ms) = input.timeout_ms {
230 if let Some(cancellation) = ctx.cancellation.clone() {
231 tokio::select! {
232 result = &mut output_future => result.map_err(|error| {
233 ToolError::ExecutionFailed(format!("failed to spawn command: {error}"))
234 })?,
235 _ = cancellation.cancelled() => return Err(ToolError::Cancelled),
236 _ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => {
237 return Err(ToolError::ExecutionFailed(format!("command timed out after {timeout_ms}ms")));
238 }
239 }
240 } else {
241 timeout(Duration::from_millis(timeout_ms), &mut output_future)
242 .await
243 .map_err(|_| {
244 ToolError::ExecutionFailed(format!(
245 "command timed out after {timeout_ms}ms"
246 ))
247 })?
248 .map_err(|error| {
249 ToolError::ExecutionFailed(format!("failed to spawn command: {error}"))
250 })?
251 }
252 } else if let Some(cancellation) = ctx.cancellation.clone() {
253 tokio::select! {
254 result = &mut output_future => result.map_err(|error| {
255 ToolError::ExecutionFailed(format!("failed to spawn command: {error}"))
256 })?,
257 _ = cancellation.cancelled() => return Err(ToolError::Cancelled),
258 }
259 } else {
260 output_future.await.map_err(|error| {
261 ToolError::ExecutionFailed(format!("failed to spawn command: {error}"))
262 })?
263 };
264
265 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
266 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
267 let status = output.status.code();
268 let success = output.status.success();
269
270 Ok(ToolResult {
271 result: ToolResultPart {
272 call_id: request.call_id,
273 output: ToolOutput::Structured(json!({
274 "stdout": stdout,
275 "stderr": stderr,
276 "success": success,
277 "exit_code": status,
278 })),
279 is_error: !success,
280 metadata: MetadataMap::new(),
281 },
282 duration: Some(duration_start.elapsed()),
283 metadata: MetadataMap::new(),
284 })
285 }
286}
287
288fn parse_input(request: &ToolRequest) -> Result<ShellExecInput, ToolError> {
289 serde_json::from_value(request.input.clone())
290 .map_err(|error| ToolError::InvalidInput(format!("invalid tool input: {error}")))
291}
292
293#[cfg(test)]
294mod tests {
295 use agentkit_capabilities::CapabilityContext;
296 use agentkit_core::{SessionId, TurnId};
297 use agentkit_tools_core::{
298 BasicToolExecutor, PermissionChecker, PermissionCode, PermissionDecision, PermissionDenial,
299 ToolExecutionOutcome, ToolExecutor,
300 };
301
302 use super::*;
303
304 struct AllowAll;
305
306 impl PermissionChecker for AllowAll {
307 fn evaluate(
308 &self,
309 _request: &dyn agentkit_tools_core::PermissionRequest,
310 ) -> PermissionDecision {
311 PermissionDecision::Allow
312 }
313 }
314
315 struct DenyCommands;
316
317 impl PermissionChecker for DenyCommands {
318 fn evaluate(
319 &self,
320 _request: &dyn agentkit_tools_core::PermissionRequest,
321 ) -> PermissionDecision {
322 PermissionDecision::Deny(PermissionDenial {
323 code: PermissionCode::CommandNotAllowed,
324 message: "commands denied in test".into(),
325 metadata: MetadataMap::new(),
326 })
327 }
328 }
329
330 #[tokio::test]
331 async fn shell_tool_executes_and_captures_output() {
332 let executor = BasicToolExecutor::new(registry());
333 let metadata = MetadataMap::new();
334 let mut ctx = ToolContext {
335 capability: CapabilityContext {
336 session_id: Some(&SessionId::new("session-1")),
337 turn_id: Some(&TurnId::new("turn-1")),
338 metadata: &metadata,
339 },
340 permissions: &AllowAll,
341 resources: &(),
342 cancellation: None,
343 };
344
345 let result = executor
346 .execute(
347 ToolRequest {
348 call_id: "call-1".into(),
349 tool_name: ToolName::new("shell.exec"),
350 input: json!({
351 "executable": "sh",
352 "argv": ["-c", "printf hello"]
353 }),
354 session_id: "session-1".into(),
355 turn_id: "turn-1".into(),
356 metadata: MetadataMap::new(),
357 },
358 &mut ctx,
359 )
360 .await;
361
362 match result {
363 ToolExecutionOutcome::Completed(result) => {
364 let value = match result.result.output {
365 ToolOutput::Structured(value) => value,
366 other => panic!("unexpected output: {other:?}"),
367 };
368 assert_eq!(value["stdout"], "hello");
369 assert_eq!(value["success"], true);
370 }
371 other => panic!("unexpected outcome: {other:?}"),
372 }
373 }
374
375 #[tokio::test]
376 async fn shell_tool_respects_permission_denial() {
377 let executor = BasicToolExecutor::new(registry());
378 let metadata = MetadataMap::new();
379 let mut ctx = ToolContext {
380 capability: CapabilityContext {
381 session_id: Some(&SessionId::new("session-1")),
382 turn_id: Some(&TurnId::new("turn-1")),
383 metadata: &metadata,
384 },
385 permissions: &DenyCommands,
386 resources: &(),
387 cancellation: None,
388 };
389
390 let result = executor
391 .execute(
392 ToolRequest {
393 call_id: "call-2".into(),
394 tool_name: ToolName::new("shell.exec"),
395 input: json!({
396 "executable": "sh",
397 "argv": ["-c", "printf nope"]
398 }),
399 session_id: "session-1".into(),
400 turn_id: "turn-1".into(),
401 metadata: MetadataMap::new(),
402 },
403 &mut ctx,
404 )
405 .await;
406
407 assert!(matches!(
408 result,
409 ToolExecutionOutcome::Failed(ToolError::PermissionDenied(_))
410 ));
411 }
412}