Skip to main content

rain_engine_skills/
shell_exec.rs

1//! Shell command execution skill with default-deny allowlist.
2
3use 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    /// Create with explicit allowlist. Empty set = deny all.
20    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    /// Permissive mode — allows any command (use only in dev).
28    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}