Skip to content

Commit 0aac8ba

Browse files
committed
add bash executor with safety checks
1 parent 9326056 commit 0aac8ba

8 files changed

Lines changed: 404 additions & 184 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ All file operations are sandboxed to your current working directory:
101101
- ❌ Cannot access absolute paths (`/etc/passwd`)
102102
- ❌ Cannot follow symlinks outside workspace
103103

104+
Bash command execution is restricted to read-only operations:
105+
106+
- ✅ Can run tests and build commands (`cargo test`, `cargo build`)
107+
- ✅ Can read files and list directories (`cat`, `ls`, `grep`)
108+
- ❌ Cannot use `sudo` or privilege escalation
109+
- ❌ Cannot modify files (`rm`, `mv`, `cp`, `chmod`, `mkdir`, `touch`)
110+
- ❌ Cannot access absolute paths or parent directories
111+
- ❌ Cannot change directories (`cd`, `pushd`, `popd`)
112+
- ❌ Cannot use output redirection (`>`, `>>`)
113+
104114
**Best Practice:** Run `sofos` from your project directory, use git to track changes.
105115

106116
## Development

src/conversation.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ impl ConversationHistory {
1818
"3. List directory contents",
1919
"4. Create directories",
2020
"5. Search the web for information",
21+
"6. Execute read-only bash commands (for testing code)",
2122
];
2223

2324
if has_code_search {
24-
features.push("6. Search code using ripgrep");
25+
features.push("7. Search code using ripgrep");
2526
}
2627

2728
let edit_instruction = if has_morph {
@@ -41,8 +42,19 @@ When helping users:
4142
- Use your tools to read files before suggesting changes
4243
{}
4344
- Search the web when you need current information or documentation
45+
- Execute bash commands to test code and verify functionality (read-only, no sudo or file modifications)
4446
- Explain your reasoning when using tools
4547
48+
Testing after code changes:
49+
- After editing code files (not comments, README, or documentation), ALWAYS test the changes using execute_bash
50+
- Run appropriate build/test commands based on the project type:
51+
* Rust: 'cargo build' and/or 'cargo test'
52+
* JavaScript/TypeScript: 'npm run build' and/or 'npm test'
53+
* Python: 'python -m pytest' or 'python -m unittest'
54+
* Go: 'go build' and/or 'go test'
55+
- If tests fail, fix the errors and test again
56+
- Do NOT run tests for changes to: comments only, README.md, documentation files, or configuration files
57+
4658
Your goal is to help users with coding tasks efficiently and accurately."#,
4759
features.join("\n"),
4860
edit_instruction

src/repl.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,21 @@ impl Repl {
180180

181181
if !tool_uses.is_empty() {
182182
for (_tool_id, tool_name, tool_input) in &tool_uses {
183-
println!(
184-
"{} {}",
185-
"Using tool:".bright_yellow().bold(),
186-
tool_name.bright_yellow()
187-
);
183+
if tool_name == "execute_bash" {
184+
if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
185+
println!(
186+
"{} {}",
187+
"Executing:".bright_green().bold(),
188+
command.bright_cyan()
189+
);
190+
}
191+
} else {
192+
println!(
193+
"{} {}",
194+
"Using tool:".bright_yellow().bold(),
195+
tool_name.bright_yellow()
196+
);
197+
}
188198

189199
let result = runtime.block_on(self.tool_executor.execute(tool_name, tool_input));
190200

src/tools/bashexec.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
use crate::error::{Result, SofosError};
2+
use std::path::PathBuf;
3+
use std::process::Command;
4+
5+
pub struct BashExecutor {
6+
workspace: PathBuf,
7+
}
8+
9+
impl BashExecutor {
10+
pub fn new(workspace: PathBuf) -> Result<Self> {
11+
Ok(Self { workspace })
12+
}
13+
14+
pub fn execute(&self, command: &str) -> Result<String> {
15+
if !self.is_safe_command(command) {
16+
return Err(SofosError::ToolExecution(
17+
"Command is not allowed. Only read-only commands are permitted.".to_string(),
18+
));
19+
}
20+
21+
let output = Command::new("sh")
22+
.arg("-c")
23+
.arg(command)
24+
.current_dir(&self.workspace)
25+
.output()
26+
.map_err(|e| SofosError::ToolExecution(format!("Failed to execute command: {}", e)))?;
27+
28+
let stdout = String::from_utf8_lossy(&output.stdout);
29+
let stderr = String::from_utf8_lossy(&output.stderr);
30+
31+
if !output.status.success() {
32+
return Ok(format!(
33+
"Command failed with exit code: {}\nSTDOUT:\n{}\nSTDERR:\n{}",
34+
output.status.code().unwrap_or(-1),
35+
stdout,
36+
stderr
37+
));
38+
}
39+
40+
let mut result = String::new();
41+
if !stdout.is_empty() {
42+
result.push_str("STDOUT:\n");
43+
result.push_str(&stdout);
44+
}
45+
if !stderr.is_empty() {
46+
if !result.is_empty() {
47+
result.push_str("\n");
48+
}
49+
result.push_str("STDERR:\n");
50+
result.push_str(&stderr);
51+
}
52+
53+
if result.is_empty() {
54+
result = "Command executed successfully (no output)".to_string();
55+
}
56+
57+
Ok(result)
58+
}
59+
60+
fn is_safe_command(&self, command: &str) -> bool {
61+
let command_lower = command.to_lowercase();
62+
63+
if command_lower.starts_with("sudo") || command_lower.contains(" sudo ") {
64+
return false;
65+
}
66+
67+
if command.contains("..") {
68+
return false;
69+
}
70+
71+
let directory_commands = ["cd ", "cd\t", "cd;", "cd&", "cd|",
72+
"pushd ", "pushd\t", "pushd;", "pushd&", "pushd|",
73+
"popd ", "popd\t", "popd;", "popd&", "popd|"];
74+
for cmd in &directory_commands {
75+
if command_lower.starts_with(cmd.trim_end())
76+
|| command_lower.contains(&format!(" {}", cmd.trim_end()))
77+
|| command_lower.contains(&format!(";{}", cmd.trim_end()))
78+
|| command_lower.contains(&format!("&&{}", cmd.trim_end()))
79+
|| command_lower.contains(&format!("||{}", cmd.trim_end()))
80+
|| command_lower.contains(&format!("|{}", cmd.trim_end()))
81+
{
82+
return false;
83+
}
84+
}
85+
86+
if command.starts_with('/') {
87+
return false;
88+
}
89+
90+
// Catches: cat /etc/passwd, ls /tmp, etc.
91+
if command.contains(" /") {
92+
return false;
93+
}
94+
95+
// Check absolute paths after pipes, semicolons, and logical operators
96+
if command.contains("|/") || command.contains(";/")
97+
|| command.contains("&&/") || command.contains("||/") {
98+
return false;
99+
}
100+
101+
let forbidden_commands = [
102+
"rm", "mv", "cp", "chmod", "chown", "chgrp",
103+
"mkdir", "rmdir", "touch", "ln", "dd",
104+
"mkfs", "mount", "umount", "kill", "killall",
105+
"pkill", "shutdown", "reboot", "halt", "poweroff",
106+
"useradd", "userdel", "groupadd", "groupdel",
107+
"passwd", "systemctl", "service", "fdisk",
108+
"parted", "mkswap", "swapon", "swapoff",
109+
];
110+
111+
for forbidden in &forbidden_commands {
112+
if command_lower.starts_with(forbidden)
113+
|| command_lower.starts_with(&format!("{} ", forbidden))
114+
{
115+
return false;
116+
}
117+
// Check chained commands
118+
if command_lower.contains(&format!("| {}", forbidden))
119+
|| command_lower.contains(&format!("; {}", forbidden))
120+
|| command_lower.contains(&format!("&& {}", forbidden))
121+
|| command_lower.contains(&format!("|| {}", forbidden))
122+
{
123+
return false;
124+
}
125+
}
126+
127+
if command.contains('>') || command.contains(">>") {
128+
return false;
129+
}
130+
131+
// Here-doc can be used for malicious input
132+
if command.contains("<<") {
133+
return false;
134+
}
135+
136+
true
137+
}
138+
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use super::*;
143+
144+
#[test]
145+
fn test_safe_commands() {
146+
let executor = BashExecutor::new(PathBuf::from(".")).unwrap();
147+
148+
assert!(executor.is_safe_command("ls -la"));
149+
assert!(executor.is_safe_command("cat file.txt"));
150+
assert!(executor.is_safe_command("grep pattern file.txt"));
151+
assert!(executor.is_safe_command("cargo test"));
152+
assert!(executor.is_safe_command("cargo build"));
153+
assert!(executor.is_safe_command("echo hello"));
154+
assert!(executor.is_safe_command("pwd"));
155+
}
156+
157+
#[test]
158+
fn test_unsafe_commands() {
159+
let executor = BashExecutor::new(PathBuf::from(".")).unwrap();
160+
161+
assert!(!executor.is_safe_command("sudo ls"));
162+
assert!(!executor.is_safe_command("rm file.txt"));
163+
assert!(!executor.is_safe_command("mv file1 file2"));
164+
assert!(!executor.is_safe_command("chmod 777 file"));
165+
assert!(!executor.is_safe_command("echo hello > file.txt"));
166+
assert!(!executor.is_safe_command("cat file.txt >> output.txt"));
167+
assert!(!executor.is_safe_command("ls | rm file.txt"));
168+
assert!(!executor.is_safe_command("ls && rm file.txt"));
169+
}
170+
171+
#[test]
172+
fn test_path_traversal_blocked() {
173+
let executor = BashExecutor::new(PathBuf::from(".")).unwrap();
174+
175+
assert!(!executor.is_safe_command("cat ../file.txt"));
176+
assert!(!executor.is_safe_command("ls ../../etc"));
177+
assert!(!executor.is_safe_command("cat ../../../etc/passwd"));
178+
assert!(!executor.is_safe_command("cat file.txt && ls .."));
179+
assert!(!executor.is_safe_command("ls | cat ../secret"));
180+
}
181+
182+
#[test]
183+
fn test_absolute_paths_blocked() {
184+
let executor = BashExecutor::new(PathBuf::from(".")).unwrap();
185+
186+
assert!(!executor.is_safe_command("/bin/ls"));
187+
assert!(!executor.is_safe_command("/etc/passwd"));
188+
assert!(!executor.is_safe_command("cat /etc/passwd"));
189+
assert!(!executor.is_safe_command("ls /tmp"));
190+
assert!(!executor.is_safe_command("cat /home/user/secret"));
191+
assert!(!executor.is_safe_command("ls && cat /etc/passwd"));
192+
assert!(!executor.is_safe_command("echo test || cat /etc/passwd"));
193+
assert!(!executor.is_safe_command("ls | grep /etc/passwd"));
194+
assert!(!executor.is_safe_command("true;/bin/bash"));
195+
}
196+
197+
#[test]
198+
fn test_directory_change_blocked() {
199+
let executor = BashExecutor::new(PathBuf::from(".")).unwrap();
200+
201+
assert!(!executor.is_safe_command("cd /tmp"));
202+
assert!(!executor.is_safe_command("cd .."));
203+
assert!(!executor.is_safe_command("cd / && ls"));
204+
assert!(!executor.is_safe_command("ls && cd /tmp"));
205+
assert!(!executor.is_safe_command("ls | cd /tmp"));
206+
assert!(!executor.is_safe_command("pushd /tmp"));
207+
assert!(!executor.is_safe_command("popd"));
208+
assert!(!executor.is_safe_command("ls && pushd .."));
209+
}
210+
}

src/tools/codesearch.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ impl CodeSearchTool {
2525
) -> Result<String> {
2626
let mut cmd = Command::new("rg");
2727

28-
// Basic options
2928
cmd.arg("--heading")
3029
.arg("--line-number")
3130
.arg("--color=never")

src/tools/filesystem.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ impl FileSystemTool {
1919
/// Validate and resolve a path relative to the workspace
2020
/// Returns an error if the path attempts to escape the workspace
2121
fn validate_path(&self, path: &str) -> Result<PathBuf> {
22-
// Reject absolute paths
2322
if Path::new(path).is_absolute() {
2423
return Err(SofosError::PathViolation(
2524
"Absolute paths are not allowed".to_string(),
2625
));
2726
}
2827

29-
// Reject paths with parent directory traversal
3028
if path.contains("..") {
3129
return Err(SofosError::PathViolation(
3230
"Parent directory traversal (..) is not allowed".to_string(),
@@ -60,7 +58,6 @@ impl FileSystemTool {
6058
}
6159
};
6260

63-
// Ensure the canonical path is within the workspace
6461
if !canonical.starts_with(&self.workspace) {
6562
return Err(SofosError::PathViolation(format!(
6663
"Path '{}' is outside the workspace",
@@ -238,20 +235,17 @@ mod tests {
238235

239236
#[test]
240237
fn test_list_nested_subdirectories() {
241-
let temp = TempDir::new().unwrap();
242-
let fs_tool = FileSystemTool::new(temp.path().to_path_buf()).unwrap();
238+
let temp_dir = tempfile::tempdir().unwrap();
239+
let fs_tool = FileSystemTool::new(temp_dir.path().to_path_buf()).unwrap();
243240

244-
// Create nested structure
245241
fs_tool.create_directory("parent/child").unwrap();
246242
fs_tool.write_file("parent/file1.txt", "test1").unwrap();
247243
fs_tool.write_file("parent/child/file2.txt", "test2").unwrap();
248244

249-
// Test listing parent
250245
let parent_entries = fs_tool.list_directory("parent").unwrap();
251246
assert!(parent_entries.contains(&"child/".to_string()));
252247
assert!(parent_entries.contains(&"file1.txt".to_string()));
253248

254-
// Test listing child
255249
let child_entries = fs_tool.list_directory("parent/child").unwrap();
256250
assert_eq!(child_entries, vec!["file2.txt"]);
257251
}

src/tools/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
pub mod bashexec;
12
pub mod codesearch;
23
pub mod filesystem;
34
pub mod search;
45
pub mod types;
56

67
use crate::api::MorphClient;
78
use crate::error::{Result, SofosError};
9+
use bashexec::BashExecutor;
810
use codesearch::CodeSearchTool;
911
use filesystem::FileSystemTool;
1012
use search::WebSearchTool;
@@ -17,6 +19,7 @@ pub struct ToolExecutor {
1719
fs_tool: FileSystemTool,
1820
search_tool: WebSearchTool,
1921
code_search_tool: Option<CodeSearchTool>,
22+
bash_executor: BashExecutor,
2023
morph_client: Option<MorphClient>,
2124
}
2225

@@ -31,9 +34,10 @@ impl ToolExecutor {
3134
};
3235

3336
Ok(Self {
34-
fs_tool: FileSystemTool::new(workspace)?,
37+
fs_tool: FileSystemTool::new(workspace.clone())?,
3538
search_tool: WebSearchTool::new()?,
3639
code_search_tool,
40+
bash_executor: BashExecutor::new(workspace)?,
3741
morph_client,
3842
})
3943
}
@@ -146,6 +150,14 @@ impl ToolExecutor {
146150
self.fs_tool.write_file(path, &merged_code)?;
147151
Ok(format!("Successfully applied fast edit to '{}'", path))
148152
}
153+
"execute_bash" => {
154+
let command = input["command"]
155+
.as_str()
156+
.ok_or_else(|| SofosError::ToolExecution("Missing 'command' parameter".to_string()))?;
157+
158+
let result = self.bash_executor.execute(command)?;
159+
Ok(result)
160+
}
149161
_ => Err(SofosError::ToolExecution(format!(
150162
"Unknown tool: {}",
151163
tool_name

0 commit comments

Comments
 (0)