Skip to main content

agent_sdk_toolkit/shell/
executor.rs

1//! Shell tool executor. Use this module only when a host policy explicitly allows
2//! command execution. Successful execution starts a host process and captures the
3//! current buffered stdout/stderr result for a core tool output; hosts that run
4//! untrusted commands should add output bounds before enabling this executor.
5//!
6use agent_sdk_core::{
7    AgentError, EffectTerminalStatus, ExecutorRef, PolicyKind, PolicyRef, ToolExecutionOutput,
8    ToolExecutionRequest, ToolExecutor, ToolPackId, ToolPackKind, ToolPackSnapshot,
9    domain::ContentRef,
10    policy::{CapabilityPermission, EffectClass, RiskClass},
11};
12
13use crate::{
14    packs::{ToolkitPackBundle, tool_snapshot},
15    testing::{InMemoryJsonArgumentStore, InMemoryToolkitContentStore},
16};
17
18use super::{command::run_command, policy::ShellExecutionPolicy, types::ShellRequest};
19
20#[derive(Clone)]
21/// Shell shell executor request or result value.
22/// Creating the value does not spawn a process; shell executors document policy checks and command side effects.
23pub struct ShellExecutor {
24    executor_ref: ExecutorRef,
25    policy: ShellExecutionPolicy,
26    arguments: InMemoryJsonArgumentStore,
27    content: InMemoryToolkitContentStore,
28}
29
30impl ShellExecutor {
31    /// Creates a new shell::executor value with explicit
32    /// caller-provided inputs. This constructor is data-only and
33    /// performs no I/O or external side effects.
34    pub fn new(
35        policy: ShellExecutionPolicy,
36        arguments: InMemoryJsonArgumentStore,
37        content: InMemoryToolkitContentStore,
38    ) -> Self {
39        Self {
40            executor_ref: ExecutorRef::new("executor.toolkit.shell.v1"),
41            policy,
42            arguments,
43            content,
44        }
45    }
46
47    /// Pack bundle.
48    /// This returns the toolkit pack bundle that registers the operation route; it does not
49    /// execute the operation.
50    pub fn pack_bundle(
51        source: agent_sdk_core::SourceRef,
52        policy_ref: PolicyRef,
53    ) -> Result<ToolkitPackBundle, AgentError> {
54        let snapshot = ToolPackSnapshot::new(
55            ToolPackId::new("toolpack.shell.v1"),
56            ToolPackKind::Shell,
57            "v1",
58            source.clone(),
59        )
60        .with_tool(tool_snapshot(
61            "cap.toolkit.shell",
62            "shell",
63            "executor.toolkit.shell.v1",
64            "schema.toolkit.shell.v1",
65            vec![
66                policy_ref,
67                PolicyRef::with_kind(PolicyKind::Sandbox, "policy.sandbox.shell.required"),
68            ],
69            vec![CapabilityPermission::Shell],
70            EffectClass::Process,
71            RiskClass::High,
72            &source,
73        ));
74        ToolkitPackBundle::from_snapshot(snapshot)
75    }
76}
77
78impl ToolExecutor for ShellExecutor {
79    fn executor_ref(&self) -> &ExecutorRef {
80        &self.executor_ref
81    }
82
83    fn execute(&self, request: &ToolExecutionRequest) -> Result<ToolExecutionOutput, AgentError> {
84        let args_ref = request
85            .effect_intent
86            .content_refs
87            .first()
88            .ok_or_else(|| AgentError::missing_required_field("shell.argument_content_ref"))?;
89        let shell_request: ShellRequest = self.arguments.get(args_ref)?;
90        if shell_request.cancel_before_start {
91            return Ok(ToolExecutionOutput {
92                terminal_status: EffectTerminalStatus::Cancelled,
93                content_refs: Vec::new(),
94                redacted_summary: "shell execution cancelled before process start".to_string(),
95                external_operation_id: None,
96                reconciliation_ref: None,
97                error_ref: None,
98            });
99        }
100        if shell_request.argv.is_empty() {
101            return Ok(ToolExecutionOutput::failed(
102                "shell argv must be structured and non-empty",
103                "shell.argv.empty",
104            ));
105        }
106        if shell_request.timeout_ms == 0 || shell_request.timeout_ms > self.policy.max_timeout_ms {
107            return Ok(ToolExecutionOutput::failed(
108                "shell requires timeout within sandbox policy",
109                "shell.timeout.policy",
110            ));
111        }
112        if shell_request.network && !self.policy.network_enabled {
113            return Ok(ToolExecutionOutput::failed(
114                "shell network access denied by sandbox policy",
115                "shell.network.policy",
116            ));
117        }
118        if !self.policy.allow_host_execution {
119            return Ok(ToolExecutionOutput::failed(
120                "shell host execution denied by sandbox policy",
121                self.policy.sandbox_policy_ref.as_str(),
122            ));
123        }
124
125        let result = run_command(&shell_request)?;
126        let content_ref = ContentRef::new(format!(
127            "content.{}.shell",
128            request.resolved_call.request.tool_call_id.as_str()
129        ));
130        self.content.put(content_ref.clone(), &result)?;
131        let mut output = ToolExecutionOutput::completed("shell process completed with output refs");
132        if result.exit_code != Some(0) {
133            output.terminal_status = EffectTerminalStatus::Failed;
134        }
135        output.content_refs.push(content_ref);
136        output.external_operation_id = Some("process.agent_owned".to_string());
137        output.reconciliation_ref = Some("process.exit_status.recorded".to_string());
138        Ok(output)
139    }
140}