rain_engine_skills/
shell_exec.rs1use crate::{AccessPolicy, SharedAccessPolicy, shared_access_policy};
4use async_trait::async_trait;
5use rain_engine_core::{
6 NativeSkill, SkillExecutionError, SkillFailureKind, SkillInvocation, SkillManifest,
7};
8use serde_json::{Value, json};
9use std::time::Duration;
10use tokio::process::Command;
11use tracing::warn;
12
13pub struct ShellExecSkill {
14 policy: SharedAccessPolicy,
15 timeout: Duration,
16}
17
18impl ShellExecSkill {
19 pub fn new(allowed_commands: std::collections::HashSet<String>, timeout: Duration) -> Self {
21 Self {
22 policy: shared_access_policy(allowed_commands, false),
23 timeout,
24 }
25 }
26
27 pub fn permissive(timeout: Duration) -> Self {
29 Self {
30 policy: shared_access_policy(std::collections::HashSet::new(), true),
31 timeout,
32 }
33 }
34
35 pub fn with_shared_policy(policy: SharedAccessPolicy, timeout: Duration) -> Self {
36 Self { policy, timeout }
37 }
38
39 async fn is_allowed(&self, command: &str) -> bool {
40 let policy = self.policy.read().await;
41 if policy.permissive {
42 return true;
43 }
44 let executable = command.split_whitespace().next().unwrap_or("");
45 policy.allowlist.contains(executable)
46 }
47
48 pub async fn access_policy(&self) -> AccessPolicy {
49 self.policy.read().await.clone()
50 }
51}
52
53pub fn manifest() -> SkillManifest {
54 crate::base_manifest(
55 "shell_exec",
56 "Execute a shell command and return stdout/stderr. Commands must be on the allowlist.",
57 json!({
58 "type": "object",
59 "properties": {
60 "command": { "type": "string", "description": "The shell command to execute" },
61 "working_dir": { "type": "string", "description": "Optional working directory" }
62 },
63 "required": ["command"]
64 }),
65 )
66}
67
68#[async_trait]
69impl NativeSkill for ShellExecSkill {
70 async fn execute(&self, invocation: SkillInvocation) -> Result<Value, SkillExecutionError> {
71 let command = invocation.args["command"].as_str().ok_or_else(|| {
72 SkillExecutionError::new(SkillFailureKind::InvalidResponse, "missing 'command' arg")
73 })?;
74
75 if !self.is_allowed(command).await {
76 warn!(command = %command, "shell_exec: command not on allowlist");
77 return Err(SkillExecutionError::new(
78 SkillFailureKind::PermissionDenied,
79 format!(
80 "command not allowed: {}",
81 command.split_whitespace().next().unwrap_or("")
82 ),
83 ));
84 }
85
86 let working_dir = invocation.args["working_dir"]
87 .as_str()
88 .map(|s| s.to_string());
89
90 let mut cmd = Command::new("sh");
91 cmd.arg("-c").arg(command);
92 if let Some(dir) = &working_dir {
93 cmd.current_dir(dir);
94 }
95
96 let output = match tokio::time::timeout(self.timeout, cmd.output()).await {
97 Ok(Ok(output)) => output,
98 Ok(Err(err)) => {
99 return Err(SkillExecutionError::new(
100 SkillFailureKind::Internal,
101 err.to_string(),
102 ));
103 }
104 Err(_) => {
105 return Err(SkillExecutionError::new(
106 SkillFailureKind::Timeout,
107 "shell command timed out",
108 ));
109 }
110 };
111
112 Ok(json!({
113 "exit_code": output.status.code(),
114 "stdout": String::from_utf8_lossy(&output.stdout),
115 "stderr": String::from_utf8_lossy(&output.stderr),
116 }))
117 }
118
119 fn requires_human_approval(&self) -> bool {
120 true
121 }
122
123 fn executor_kind(&self) -> &'static str {
124 "native:shell_exec"
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use rain_engine_core::{
132 AgentContextSnapshot, AgentId, AgentStateSnapshot, EnginePolicy, SkillInvocation,
133 };
134
135 fn invocation(command: &str) -> SkillInvocation {
136 SkillInvocation {
137 call_id: "call-1".to_string(),
138 manifest: manifest(),
139 args: json!({ "command": command }),
140 dry_run: false,
141 context: AgentContextSnapshot {
142 session_id: "session".to_string(),
143 granted_scopes: vec!["tool:run".to_string()],
144 trigger_id: "trigger".to_string(),
145 idempotency_key: None,
146 current_step: 0,
147 max_steps: 1,
148 history: Vec::new(),
149 prior_tool_results: Vec::new(),
150 session_cost_usd: 0.0,
151 state: AgentStateSnapshot {
152 agent_id: AgentId("session".to_string()),
153 profile: None,
154 goals: Vec::new(),
155 tasks: Vec::new(),
156 observations: Vec::new(),
157 artifacts: Vec::new(),
158 resources: Vec::new(),
159 relationships: Vec::new(),
160 pending_wake: None,
161 },
162 policy: EnginePolicy::default(),
163 active_execution_plan: None,
164 },
165 }
166 }
167
168 #[tokio::test]
169 async fn empty_allowlist_denies_by_default() {
170 let skill = ShellExecSkill::new(std::collections::HashSet::new(), Duration::from_secs(1));
171 let err = skill
172 .execute(invocation("echo denied"))
173 .await
174 .expect_err("empty allowlist denies");
175 assert_eq!(err.kind, SkillFailureKind::PermissionDenied);
176 }
177
178 #[tokio::test]
179 async fn explicit_permissive_mode_allows_commands() {
180 let skill = ShellExecSkill::permissive(Duration::from_secs(1));
181 let output = skill
182 .execute(invocation("printf allowed"))
183 .await
184 .expect("permissive command");
185 assert_eq!(output["stdout"], json!("allowed"));
186 }
187}