rustyclaw_core/tools/
helpers.rs1use crate::process_manager::{ProcessManager, SharedProcessManager};
4use crate::sandbox::{Sandbox, SandboxMode, SandboxPolicy};
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex, OnceLock};
7use tracing::{debug, warn};
8
9static PROCESS_MANAGER: OnceLock<SharedProcessManager> = OnceLock::new();
13
14pub fn process_manager() -> &'static SharedProcessManager {
16 PROCESS_MANAGER.get_or_init(|| Arc::new(Mutex::new(ProcessManager::new())))
17}
18
19static SANDBOX: OnceLock<Sandbox> = OnceLock::new();
23
24pub fn init_sandbox(
26 mode: SandboxMode,
27 workspace: PathBuf,
28 credentials_dir: PathBuf,
29 deny_paths: Vec<PathBuf>,
30) {
31 debug!(?mode, ?workspace, "Initializing sandbox");
32 let mut policy = SandboxPolicy::protect_credentials(&credentials_dir, &workspace);
33 for path in deny_paths {
34 policy = policy.deny_read(path.clone()).deny_write(path);
35 }
36 let sandbox = Sandbox::with_mode(mode, policy);
37 let _ = SANDBOX.set(sandbox);
38}
39
40pub fn sandbox() -> Option<&'static Sandbox> {
42 SANDBOX.get()
43}
44
45pub fn run_sandboxed_command(command: &str, cwd: &Path) -> Result<std::process::Output, String> {
47 if let Some(sb) = SANDBOX.get() {
48 debug!(mode = ?sb.mode, cwd = %cwd.display(), "Running sandboxed command");
49 let mut policy = sb.policy.clone();
51 policy.workspace = cwd.to_path_buf();
52 crate::sandbox::run_sandboxed(command, &policy, sb.mode)
53 } else {
54 debug!(cwd = %cwd.display(), "Running unsandboxed command (no sandbox configured)");
55 std::process::Command::new("sh")
57 .arg("-c")
58 .arg(command)
59 .current_dir(cwd)
60 .output()
61 .map_err(|e| format!("Command failed: {}", e))
62 }
63}
64
65static CREDENTIALS_DIR: OnceLock<PathBuf> = OnceLock::new();
69
70use crate::secrets::SecretsManager;
73
74pub type SharedVault = Arc<tokio::sync::Mutex<SecretsManager>>;
76
77static VAULT: OnceLock<SharedVault> = OnceLock::new();
79
80pub fn set_vault(vault: SharedVault) {
82 let _ = VAULT.set(vault);
83}
84
85pub fn vault() -> Option<&'static SharedVault> {
87 VAULT.get()
88}
89
90pub fn set_credentials_dir(path: PathBuf) {
92 let _ = CREDENTIALS_DIR.set(path);
93}
94
95pub fn command_references_credentials(command: &str) -> bool {
97 if let Some(cred_dir) = CREDENTIALS_DIR.get() {
98 let cred_str = cred_dir.to_string_lossy();
99 command.contains(cred_str.as_ref())
100 } else {
101 false
102 }
103}
104
105pub fn is_protected_path(path: &Path) -> bool {
107 if let Some(cred_dir) = CREDENTIALS_DIR.get() {
108 let canon_cred = match cred_dir.canonicalize() {
110 Ok(p) => p,
111 Err(_) => return false, };
113 let canon_path = match path.canonicalize() {
114 Ok(p) => p,
115 Err(_) => {
116 return path.starts_with(cred_dir);
119 }
120 };
121 canon_path.starts_with(&canon_cred)
122 } else {
123 false
124 }
125}
126
127pub const VAULT_ACCESS_DENIED: &str = "Access denied: the credentials directory is protected. Use the secrets_list / secrets_get / secrets_store tools instead.";
129
130pub fn resolve_path(workspace_dir: &Path, path: &str) -> PathBuf {
135 let p = Path::new(path);
136 if p.is_absolute() {
137 p.to_path_buf()
138 } else {
139 workspace_dir.join(p)
140 }
141}
142
143pub fn expand_tilde(p: &str) -> PathBuf {
145 if p.starts_with('~') {
146 dirs::home_dir()
147 .map(|h| h.join(p.strip_prefix("~/").unwrap_or(&p[1..])))
148 .unwrap_or_else(|| PathBuf::from(p))
149 } else {
150 PathBuf::from(p)
151 }
152}
153
154pub fn display_path(found: &Path, workspace_dir: &Path) -> String {
161 if let Ok(rel) = found.strip_prefix(workspace_dir) {
162 rel.display().to_string()
163 } else {
164 found.display().to_string()
165 }
166}
167
168pub fn should_visit(entry: &walkdir::DirEntry) -> bool {
170 let name = entry.file_name().to_string_lossy();
171 if entry.file_type().is_dir() {
172 if matches!(
173 name.as_ref(),
174 ".git" | "node_modules" | "target" | ".hg" | ".svn" | "__pycache__" | "dist" | "build"
175 ) {
176 return false;
177 }
178 if is_protected_path(entry.path()) {
180 return false;
181 }
182 true
183 } else {
184 true
185 }
186}
187
188const MAX_TOOL_OUTPUT_BYTES: usize = 50_000;
192
193fn is_likely_garbage(s: &str) -> bool {
195 let lower = s.to_lowercase();
197 if lower.contains("<!doctype") || lower.contains("<html") {
198 return true;
199 }
200
201 if s.contains("data:image/") || s.contains("data:application/") {
203 return true;
204 }
205
206 let lines: Vec<&str> = s.lines().collect();
208 let long_dense_lines = lines
209 .iter()
210 .filter(|line| line.len() > 500 && !line.contains(' '))
211 .count();
212 if long_dense_lines > 3 {
213 return true;
214 }
215
216 false
217}
218
219pub fn sanitize_tool_output(output: String) -> String {
221 if is_likely_garbage(&output) {
223 let preview_len = output.len().min(500);
224 let preview: String = output.chars().take(preview_len).collect();
225 warn!(bytes = output.len(), "Tool returned HTML/binary content");
226 return format!(
227 "[Warning: Tool returned HTML/binary content ({} bytes) — likely not useful]\n\nPreview:\n{}...",
228 output.len(),
229 preview
230 );
231 }
232
233 if output.len() > MAX_TOOL_OUTPUT_BYTES {
235 debug!(
236 bytes = output.len(),
237 max = MAX_TOOL_OUTPUT_BYTES,
238 "Truncating large tool output"
239 );
240 let truncated: String = output.chars().take(MAX_TOOL_OUTPUT_BYTES).collect();
241 format!(
242 "{}...\n\n[Truncated: {} bytes total, showing first {}]",
243 truncated,
244 output.len(),
245 MAX_TOOL_OUTPUT_BYTES
246 )
247 } else {
248 output
249 }
250}