1use std::hash::{Hash, Hasher};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use agent_client_protocol as acp;
22use schemars::JsonSchema;
23use serde::Deserialize;
24use tokio::sync::{mpsc, oneshot};
25use zeph_tools::{
26 DiffData, ToolCall, ToolError, ToolOutput,
27 executor::deserialize_params,
28 registry::{InvocationHint, ToolDef},
29};
30
31use crate::error::AcpError;
32use crate::permission::AcpPermissionGate;
33
34const MAX_WRITE_BYTES: usize = 10 * 1024 * 1024; fn is_binary(content: &[u8]) -> bool {
37 content.contains(&0) }
39
40fn hash_content(content: &str) -> u64 {
42 let mut hasher = std::collections::hash_map::DefaultHasher::new();
43 content.hash(&mut hasher);
44 hasher.finish()
45}
46
47fn compute_diff_data(old: &str, new: &str, path: &str) -> DiffData {
48 DiffData {
49 file_path: path.to_owned(),
50 old_content: old.to_owned(),
51 new_content: new.to_owned(),
52 }
53}
54
55enum FsRequest {
56 Read {
57 session_id: acp::schema::SessionId,
58 path: PathBuf,
59 line: Option<u32>,
60 limit: Option<u32>,
61 reply: oneshot::Sender<Result<String, AcpError>>,
62 },
63 Write {
64 session_id: acp::schema::SessionId,
65 path: PathBuf,
66 content: String,
67 reply: oneshot::Sender<Result<(), AcpError>>,
68 },
69 ReadForDiff {
70 session_id: acp::schema::SessionId,
71 path: PathBuf,
72 reply: oneshot::Sender<Result<Option<String>, AcpError>>,
73 },
74}
75
76#[derive(Clone)]
82pub struct AcpFileExecutor {
83 session_id: acp::schema::SessionId,
84 request_tx: mpsc::UnboundedSender<FsRequest>,
85 can_read: bool,
86 can_write: bool,
87 cwd: PathBuf,
88 permission_gate: Option<AcpPermissionGate>,
89}
90
91impl AcpFileExecutor {
92 pub fn new(
97 conn: Arc<acp::ConnectionTo<acp::Client>>,
98 session_id: acp::schema::SessionId,
99 can_read: bool,
100 can_write: bool,
101 cwd: PathBuf,
102 permission_gate: Option<AcpPermissionGate>,
103 ) -> (Self, impl std::future::Future<Output = ()> + Send + 'static) {
104 let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
105 let (tx, rx) = mpsc::unbounded_channel::<FsRequest>();
106 let handler = async move { run_fs_handler(conn, rx).await };
107 (
108 Self {
109 session_id,
110 request_tx: tx,
111 can_read,
112 can_write,
113 cwd,
114 permission_gate,
115 },
116 handler,
117 )
118 }
119
120 fn resolve_path(&self, path: &Path) -> PathBuf {
122 if path.is_absolute() {
123 path.to_path_buf()
124 } else {
125 self.cwd.join(path)
126 }
127 }
128
129 async fn read(
130 &self,
131 path: PathBuf,
132 line: Option<u32>,
133 limit: Option<u32>,
134 ) -> Result<String, AcpError> {
135 let (reply_tx, reply_rx) = oneshot::channel();
136 self.request_tx
137 .send(FsRequest::Read {
138 session_id: self.session_id.clone(),
139 path,
140 line,
141 limit,
142 reply: reply_tx,
143 })
144 .map_err(|_| AcpError::ChannelClosed)?;
145 reply_rx.await.map_err(|_| AcpError::ChannelClosed)?
146 }
147
148 async fn write(&self, path: PathBuf, content: String) -> Result<(), AcpError> {
149 let (reply_tx, reply_rx) = oneshot::channel();
150 self.request_tx
151 .send(FsRequest::Write {
152 session_id: self.session_id.clone(),
153 path,
154 content,
155 reply: reply_tx,
156 })
157 .map_err(|_| AcpError::ChannelClosed)?;
158 reply_rx.await.map_err(|_| AcpError::ChannelClosed)?
159 }
160
161 async fn read_for_diff(&self, path: PathBuf) -> Result<Option<String>, AcpError> {
162 let (reply_tx, reply_rx) = oneshot::channel();
163 self.request_tx
164 .send(FsRequest::ReadForDiff {
165 session_id: self.session_id.clone(),
166 path,
167 reply: reply_tx,
168 })
169 .map_err(|_| AcpError::ChannelClosed)?;
170 reply_rx.await.map_err(|_| AcpError::ChannelClosed)?
171 }
172}
173
174#[derive(Deserialize, JsonSchema)]
175struct ReadFileParams {
176 path: String,
177 #[serde(default)]
178 line: Option<u32>,
179 #[serde(default)]
180 limit: Option<u32>,
181}
182
183#[derive(Deserialize, JsonSchema)]
184struct WriteFileParams {
185 path: String,
186 content: String,
187}
188
189#[derive(Deserialize, JsonSchema)]
190struct ListDirectoryParams {
191 path: String,
192}
193
194#[derive(Deserialize, JsonSchema)]
195struct FindPathParams {
196 path: String,
198 pattern: String,
200}
201
202fn validate_within_sandbox(resolved: &Path, sandbox: &Path) -> Result<(), ToolError> {
212 let sandbox_canonical = sandbox
213 .canonicalize()
214 .unwrap_or_else(|_| sandbox.to_path_buf());
215 match resolved.canonicalize() {
216 Ok(canonical) => {
217 if canonical.starts_with(&sandbox_canonical) {
218 Ok(())
219 } else {
220 Err(ToolError::SandboxViolation {
221 path: resolved.display().to_string(),
222 })
223 }
224 }
225 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
226 let mut ancestor = resolved.parent();
228 while let Some(dir) = ancestor {
229 match dir.canonicalize() {
230 Ok(canonical) => {
231 if canonical.starts_with(&sandbox_canonical) {
232 return Ok(());
233 }
234 return Err(ToolError::SandboxViolation {
235 path: resolved.display().to_string(),
236 });
237 }
238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
239 ancestor = dir.parent();
240 }
241 Err(_) => {
242 return Err(ToolError::SandboxViolation {
243 path: resolved.display().to_string(),
244 });
245 }
246 }
247 }
248 Err(ToolError::SandboxViolation {
249 path: resolved.display().to_string(),
250 })
251 }
252 Err(_) => Err(ToolError::SandboxViolation {
253 path: resolved.display().to_string(),
254 }),
255 }
256}
257
258fn validate_path(raw: &str) -> Result<PathBuf, ToolError> {
259 let path = PathBuf::from(raw);
260 if path.components().any(|c| c.as_os_str() == "..") {
262 return Err(ToolError::SandboxViolation {
263 path: raw.to_owned(),
264 });
265 }
266 Ok(path)
270}
271
272impl zeph_tools::ToolExecutor for AcpFileExecutor {
273 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
274 Ok(None)
275 }
276
277 fn tool_definitions(&self) -> Vec<ToolDef> {
278 let mut defs = Vec::new();
279 if self.can_read {
280 defs.push(ToolDef {
281 id: "read_file".into(),
282 description: "Read a file from the IDE workspace with line numbers.\n\nParameters: path (string, required) - file path relative to workspace root; offset (integer, optional) - start line; limit (integer, optional) - max lines\nReturns: file content with line numbers, structured for IDE display\nErrors: file not found; path outside workspace; I/O failure\nExample: {\"path\": \"src/main.rs\", \"offset\": 0, \"limit\": 100}".into(),
283 schema: schemars::schema_for!(ReadFileParams),
284 invocation: InvocationHint::ToolCall,
285 output_schema: None,
286 });
287 defs.push(ToolDef {
288 id: "list_directory".into(),
289 description: "List files and directories at the given path in the IDE workspace.\n\nParameters: path (string, required) - directory path relative to workspace root\nReturns: sorted listing with type indicators\nErrors: path not found; path outside workspace\nExample: {\"path\": \"src/\"}".into(),
290 schema: schemars::schema_for!(ListDirectoryParams),
291 invocation: InvocationHint::ToolCall,
292 output_schema: None,
293 });
294 defs.push(ToolDef {
295 id: "find_path".into(),
296 description: "Find files matching a glob pattern in the IDE workspace.\n\nParameters: pattern (string, required) - glob pattern\nReturns: matching file paths relative to workspace root\nErrors: path outside workspace\nExample: {\"pattern\": \"**/*.rs\"}".into(),
297 schema: schemars::schema_for!(FindPathParams),
298 invocation: InvocationHint::ToolCall,
299 output_schema: None,
300 });
301 }
302 if self.can_write && self.permission_gate.is_some() {
304 defs.push(ToolDef {
305 id: "write_file".into(),
306 description: "Create or overwrite a file in the IDE workspace.\n\nParameters: path (string, required) - file path; content (string, required) - file content\nReturns: confirmation with bytes written\nErrors: permission denied; path outside workspace; I/O failure\nExample: {\"path\": \"output.txt\", \"content\": \"Hello\"}".into(),
307 schema: schemars::schema_for!(WriteFileParams),
308 invocation: InvocationHint::ToolCall,
309 output_schema: None,
310 });
311 }
312 defs
313 }
314
315 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
316 match call.tool_id.as_str() {
317 "read_file" if self.can_read => {
318 let params: ReadFileParams = deserialize_params(&call.params)?;
319 let path = validate_path(¶ms.path)?;
320 let resolved = self.resolve_path(&path);
321 validate_within_sandbox(&resolved, &self.cwd)?;
325 let resolved_str = resolved.to_string_lossy().into_owned();
326 let content = self
327 .read(resolved, params.line, params.limit)
328 .await
329 .map_err(|e| ToolError::InvalidParams {
330 message: e.to_string(),
331 })?;
332 let total_lines = content.lines().count();
333 let start_line = params.line.unwrap_or(1);
334 let raw_response = Some(serde_json::json!({
335 "type": "text",
336 "file": {
337 "filePath": &resolved_str,
338 "content": &content,
339 "numLines": total_lines,
340 "startLine": start_line,
341 "totalLines": total_lines
342 }
343 }));
344 Ok(Some(ToolOutput {
345 tool_name: zeph_tools::ToolName::new("read_file"),
346 summary: content,
347 blocks_executed: 1,
348 filter_stats: None,
349 diff: None,
350 streamed: false,
351 terminal_id: None,
352 locations: Some(vec![resolved_str]),
353 raw_response,
354 claim_source: Some(zeph_tools::ClaimSource::FileSystem),
355 }))
356 }
357 "write_file" if self.can_write => {
358 let params: WriteFileParams = deserialize_params(&call.params)?;
359 self.handle_write_file(params).await
360 }
361 "list_directory" if self.can_read => {
362 let params: ListDirectoryParams = deserialize_params(&call.params)?;
363 self.handle_list_directory(params)
364 }
365 "find_path" if self.can_read => {
366 let params: FindPathParams = deserialize_params(&call.params)?;
367 self.handle_find_path(¶ms)
368 }
369 _ => Ok(None),
370 }
371 }
372}
373
374impl AcpFileExecutor {
375 async fn handle_write_file(
376 &self,
377 params: WriteFileParams,
378 ) -> Result<Option<ToolOutput>, ToolError> {
379 if params.content.len() > MAX_WRITE_BYTES {
381 return Err(ToolError::InvalidParams {
382 message: format!("content exceeds {MAX_WRITE_BYTES} byte limit"),
383 });
384 }
385 if is_binary(params.content.as_bytes()) {
387 return Err(ToolError::InvalidParams {
388 message: "binary content not supported for write_file".into(),
389 });
390 }
391 let path = validate_path(¶ms.path)?;
392 let resolved = self.resolve_path(&path);
393 validate_within_sandbox(&resolved, &self.cwd)?;
394
395 let old_content =
397 self.read_for_diff(resolved.clone())
398 .await
399 .map_err(|e| ToolError::InvalidParams {
400 message: e.to_string(),
401 })?;
402
403 if let Some(ref old) = old_content
405 && is_binary(old.as_bytes())
406 {
407 return Err(ToolError::InvalidParams {
408 message: "existing file is binary; cannot diff".into(),
409 });
410 }
411
412 let old_hash = old_content.as_deref().map(hash_content);
414
415 if self.permission_gate.is_none() {
416 tracing::warn!(
417 path = %resolved.display(),
418 "AcpFileExecutor: write_file called without permission gate"
419 );
420 }
421
422 if let Some(gate) = &self.permission_gate {
424 let diff = acp::schema::Diff::new(resolved.clone(), params.content.clone())
425 .old_text(old_content.clone());
426 let fields = acp::schema::ToolCallUpdateFields::new()
427 .title("write_file".to_owned())
428 .content(vec![acp::schema::ToolCallContent::Diff(diff)])
429 .raw_input(serde_json::json!({ "path": params.path }));
430 let tool_call = acp::schema::ToolCallUpdate::new("write_file".to_owned(), fields);
431 let allowed = gate
432 .check_permission(self.session_id.clone(), tool_call)
433 .await
434 .map_err(|e| ToolError::InvalidParams {
435 message: e.to_string(),
436 })?;
437 if !allowed {
438 return Err(ToolError::Blocked {
439 command: "write_file: diff rejected".to_owned(),
440 });
441 }
442 }
443
444 let current_content =
446 self.read_for_diff(resolved.clone())
447 .await
448 .map_err(|e| ToolError::InvalidParams {
449 message: e.to_string(),
450 })?;
451 if old_hash != current_content.as_deref().map(hash_content) {
452 return Err(ToolError::InvalidParams {
453 message: "file changed between diff preview and write; aborting".into(),
454 });
455 }
456
457 let diff_data = Some(compute_diff_data(
458 old_content.as_deref().unwrap_or(""),
459 ¶ms.content,
460 ¶ms.path,
461 ));
462 self.write(resolved, params.content.clone())
463 .await
464 .map_err(|e| ToolError::InvalidParams {
465 message: e.to_string(),
466 })?;
467 Ok(Some(ToolOutput {
468 tool_name: zeph_tools::ToolName::new("write_file"),
469 summary: format!("wrote {}", params.path),
470 blocks_executed: 1,
471 filter_stats: None,
472 diff: diff_data,
473 streamed: false,
474 terminal_id: None,
475 locations: Some(vec![params.path]),
476 raw_response: None,
477 claim_source: Some(zeph_tools::ClaimSource::FileSystem),
478 }))
479 }
480
481 fn handle_list_directory(
482 &self,
483 params: ListDirectoryParams,
484 ) -> Result<Option<ToolOutput>, ToolError> {
485 let path = validate_path(¶ms.path)?;
486 let dir = self.resolve_path(&path);
487 validate_within_sandbox(&dir, &self.cwd)?;
488 let entries = std::fs::read_dir(&dir).map_err(|e| ToolError::InvalidParams {
489 message: format!("cannot read directory {}: {e}", params.path),
490 })?;
491 let mut items: Vec<serde_json::Value> = Vec::new();
492 for entry in entries {
493 let entry = entry.map_err(|e| ToolError::InvalidParams {
494 message: format!("directory entry error: {e}"),
495 })?;
496 let meta = entry
498 .path()
499 .symlink_metadata()
500 .map_err(|e| ToolError::InvalidParams {
501 message: format!("metadata error: {e}"),
502 })?;
503 if meta.file_type().is_symlink()
505 && validate_within_sandbox(&entry.path(), &self.cwd).is_err()
506 {
507 continue;
508 }
509 items.push(serde_json::json!({
510 "name": entry.file_name().to_string_lossy(),
511 "is_dir": meta.is_dir(),
512 "size": meta.len(),
513 "is_symlink": meta.file_type().is_symlink(),
514 }));
515 }
516 items.sort_by(|a, b| {
517 let a_name = a["name"].as_str().unwrap_or("");
518 let b_name = b["name"].as_str().unwrap_or("");
519 a_name.cmp(b_name)
520 });
521 let summary = serde_json::to_string(&items).unwrap_or_default();
522 Ok(Some(ToolOutput {
523 tool_name: zeph_tools::ToolName::new("list_directory"),
524 summary,
525 blocks_executed: 1,
526 filter_stats: None,
527 diff: None,
528 streamed: false,
529 terminal_id: None,
530 locations: Some(vec![params.path]),
531 raw_response: None,
532 claim_source: Some(zeph_tools::ClaimSource::FileSystem),
533 }))
534 }
535
536 fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
537 const MAX_RESULTS: usize = 1000;
538
539 let path = validate_path(¶ms.path)?;
540 let base = self.resolve_path(&path);
541
542 if params
544 .pattern
545 .split('/')
546 .any(|seg| seg == ".." || seg.starts_with('/'))
547 {
548 return Err(ToolError::SandboxViolation {
549 path: params.pattern.clone(),
550 });
551 }
552
553 validate_within_sandbox(&base, &self.cwd)?;
554
555 let glob_str = format!("{}/{}", params.path, params.pattern);
556 let mut matches: Vec<String> = Vec::new();
557 for entry in glob::glob(&glob_str).map_err(|e| ToolError::InvalidParams {
558 message: format!("invalid glob pattern: {e}"),
559 })? {
560 if matches.len() >= MAX_RESULTS {
561 break;
562 }
563 if let Ok(p) = entry {
564 if validate_within_sandbox(&p, &self.cwd).is_err() {
566 continue;
567 }
568 matches.push(p.display().to_string());
569 }
570 }
571
572 let summary = matches.join("\n");
573 Ok(Some(ToolOutput {
574 tool_name: zeph_tools::ToolName::new("find_path"),
575 summary,
576 blocks_executed: 1,
577 filter_stats: None,
578 diff: None,
579 streamed: false,
580 terminal_id: None,
581 locations: None,
582 raw_response: None,
583 claim_source: Some(zeph_tools::ClaimSource::FileSystem),
584 }))
585 }
586}
587
588async fn run_fs_handler(
589 conn: Arc<acp::ConnectionTo<acp::Client>>,
590 mut rx: mpsc::UnboundedReceiver<FsRequest>,
591) {
592 while let Some(req) = rx.recv().await {
593 match req {
594 FsRequest::Read {
595 session_id,
596 path,
597 line,
598 limit,
599 reply,
600 } => {
601 let req = acp::schema::ReadTextFileRequest::new(session_id, path)
602 .line(line)
603 .limit(limit);
604 let result = conn
605 .send_request(req)
606 .block_task()
607 .await
608 .map(|r| r.content)
609 .map_err(|e| AcpError::ClientError(e.to_string()));
610 reply.send(result).ok();
611 }
612 FsRequest::Write {
613 session_id,
614 path,
615 content,
616 reply,
617 } => {
618 let result = conn
619 .send_request(acp::schema::WriteTextFileRequest::new(
620 session_id, path, content,
621 ))
622 .block_task()
623 .await
624 .map(|_| ())
625 .map_err(|e| AcpError::ClientError(e.to_string()));
626 reply.send(result).ok();
627 }
628 FsRequest::ReadForDiff {
629 session_id,
630 path,
631 reply,
632 } => {
633 let req = acp::schema::ReadTextFileRequest::new(session_id, path);
634 let result = match conn.send_request(req).block_task().await {
635 Ok(r) => Ok(Some(r.content)),
636 Err(e) if e.code == acp::ErrorCode::ResourceNotFound => Ok(None),
637 Err(e) => Err(AcpError::ClientError(e.to_string())),
638 };
639 reply.send(result).ok();
640 }
641 }
642 }
643}
644
645#[cfg(any())] mod tests {
648 use std::rc::Rc;
649
650 use zeph_tools::ToolExecutor as _;
651
652 use super::*;
653
654 fn test_cwd() -> PathBuf {
655 std::env::temp_dir()
656 }
657
658 fn test_path(name: &str) -> String {
659 test_cwd().join(name).to_string_lossy().into_owned()
660 }
661
662 struct NoopPermClient;
664
665 #[async_trait::async_trait(?Send)]
666 impl acp::Client for NoopPermClient {
667 async fn request_permission(
668 &self,
669 _args: acp::schema::RequestPermissionRequest,
670 ) -> acp::Result<acp::RequestPermissionResponse> {
671 Ok(acp::RequestPermissionResponse::new(
672 acp::schema::RequestPermissionOutcome::Selected(
673 acp::SelectedPermissionOutcome::new("allow_once"),
674 ),
675 ))
676 }
677
678 async fn session_notification(
679 &self,
680 _args: acp::schema::SessionNotification,
681 ) -> acp::Result<()> {
682 Ok(())
683 }
684 }
685
686 struct FakeClient {
687 content: String,
688 }
689
690 #[async_trait::async_trait(?Send)]
691 impl acp::Client for FakeClient {
692 async fn request_permission(
693 &self,
694 _args: acp::schema::RequestPermissionRequest,
695 ) -> acp::Result<acp::RequestPermissionResponse> {
696 Err(acp::Error::method_not_found())
697 }
698
699 async fn read_text_file(
700 &self,
701 _args: acp::schema::ReadTextFileRequest,
702 ) -> acp::Result<acp::ReadTextFileResponse> {
703 Ok(acp::ReadTextFileResponse::new(self.content.clone()))
704 }
705
706 async fn write_text_file(
707 &self,
708 _args: acp::schema::WriteTextFileRequest,
709 ) -> acp::Result<acp::WriteTextFileResponse> {
710 Ok(acp::WriteTextFileResponse::new())
711 }
712
713 async fn session_notification(
714 &self,
715 _args: acp::schema::SessionNotification,
716 ) -> acp::Result<()> {
717 Ok(())
718 }
719 }
720
721 #[tokio::test]
722 async fn read_file_tool_call_returns_content() {
723 let local = tokio::task::LocalSet::new();
724 local
725 .run_until(async {
726 let conn = Rc::new(FakeClient {
727 content: "hello world".to_owned(),
728 });
729 let sid = acp::schema::SessionId::new("s1");
730 let (exec, handler) =
731 AcpFileExecutor::new(conn, sid, true, false, test_cwd(), None);
732 tokio::task::spawn_local(handler);
733
734 let mut params = serde_json::Map::new();
735 params.insert("path".to_owned(), serde_json::json!(test_path("test.txt")));
736 let call = ToolCall {
737 tool_id: zeph_tools::ToolName::new("read_file"),
738 params,
739 caller_id: None,
740 };
741
742 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
743 assert_eq!(result.summary, "hello world");
744 assert_eq!(
745 result.locations.as_deref(),
746 Some(&[test_path("test.txt")][..])
747 );
748 })
749 .await;
750 }
751
752 #[tokio::test]
753 async fn write_file_tool_call_succeeds() {
754 let local = tokio::task::LocalSet::new();
755 local
756 .run_until(async {
757 let conn = Rc::new(FakeClient {
758 content: String::new(),
759 });
760 let sid = acp::schema::SessionId::new("s1");
761 let (exec, handler) =
762 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
763 tokio::task::spawn_local(handler);
764
765 let mut params = serde_json::Map::new();
766 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
767 params.insert("content".to_owned(), serde_json::json!("data"));
768 let call = ToolCall {
769 tool_id: zeph_tools::ToolName::new("write_file"),
770 params,
771 caller_id: None,
772 };
773
774 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
775 assert!(result.summary.contains(&test_path("out.txt")));
776 assert_eq!(
777 result.locations.as_deref(),
778 Some(&[test_path("out.txt")][..])
779 );
780 })
781 .await;
782 }
783
784 #[tokio::test]
785 async fn unknown_tool_returns_none() {
786 let local = tokio::task::LocalSet::new();
787 local
788 .run_until(async {
789 let conn = Rc::new(FakeClient {
790 content: String::new(),
791 });
792 let sid = acp::schema::SessionId::new("s1");
793 let (exec, handler) = AcpFileExecutor::new(conn, sid, true, true, test_cwd(), None);
794 tokio::task::spawn_local(handler);
795
796 let call = ToolCall {
797 tool_id: zeph_tools::ToolName::new("unknown"),
798 params: serde_json::Map::new(),
799 caller_id: None,
800 };
801 let result = exec.execute_tool_call(&call).await.unwrap();
802 assert!(result.is_none());
803 })
804 .await;
805 }
806
807 #[test]
808 fn tool_definitions_gated_by_capabilities() {
809 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
810 let exec_read_only = AcpFileExecutor {
811 session_id: acp::schema::SessionId::new("s"),
812 request_tx: tx.clone(),
813 can_read: true,
814 can_write: false,
815 cwd: test_cwd(),
816 permission_gate: None,
817 };
818 let defs = exec_read_only.tool_definitions();
819 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
820 assert!(ids.contains(&"read_file"));
821 assert!(ids.contains(&"list_directory"));
822 assert!(ids.contains(&"find_path"));
823 assert!(!ids.contains(&"write_file"));
824 assert!(defs[0].description.contains("IDE workspace"));
825
826 let exec_write_no_gate = AcpFileExecutor {
828 session_id: acp::schema::SessionId::new("s"),
829 request_tx: tx.clone(),
830 can_read: false,
831 can_write: true,
832 cwd: test_cwd(),
833 permission_gate: None,
834 };
835 let defs = exec_write_no_gate.tool_definitions();
836 assert_eq!(
837 defs.len(),
838 0,
839 "write_file must not appear without permission gate"
840 );
841
842 let tmp_dir = tempfile::tempdir().unwrap();
843 let perm_file = tmp_dir.path().join("perms.toml");
844 let perm_conn = Rc::new(NoopPermClient);
845 let (gate, _handler) = AcpPermissionGate::new(perm_conn, Some(perm_file));
846 let exec_write_with_gate = AcpFileExecutor {
847 session_id: acp::schema::SessionId::new("s"),
848 request_tx: tx,
849 can_read: false,
850 can_write: true,
851 cwd: test_cwd(),
852 permission_gate: Some(gate),
853 };
854 let defs = exec_write_with_gate.tool_definitions();
855 assert_eq!(defs.len(), 1);
856 assert_eq!(defs[0].id, "write_file");
857 assert!(defs[0].description.contains("IDE workspace"));
858 }
859
860 #[tokio::test]
861 async fn list_directory_returns_entries() {
862 let dir = tempfile::tempdir().unwrap();
863 std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
864 std::fs::create_dir(dir.path().join("subdir")).unwrap();
865
866 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
867 let exec = AcpFileExecutor {
868 session_id: acp::schema::SessionId::new("s"),
869 request_tx: tx,
870 can_read: true,
871 can_write: false,
872 cwd: dir.path().to_path_buf(),
873 permission_gate: None,
874 };
875
876 let mut params = serde_json::Map::new();
877 params.insert(
878 "path".to_owned(),
879 serde_json::json!(dir.path().to_str().unwrap()),
880 );
881 let call = ToolCall {
882 tool_id: zeph_tools::ToolName::new("list_directory"),
883 params,
884 caller_id: None,
885 };
886 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
887 assert!(result.summary.contains("file.txt"));
888 assert!(result.summary.contains("subdir"));
889 }
890
891 #[tokio::test]
892 async fn find_path_matches_glob() {
893 let dir = tempfile::tempdir().unwrap();
894 std::fs::write(dir.path().join("foo.rs"), "fn main() {}").unwrap();
895 std::fs::write(dir.path().join("bar.toml"), "[package]").unwrap();
896
897 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
898 let exec = AcpFileExecutor {
899 session_id: acp::schema::SessionId::new("s"),
900 request_tx: tx,
901 can_read: true,
902 can_write: false,
903 cwd: dir.path().to_path_buf(),
904 permission_gate: None,
905 };
906
907 let mut params = serde_json::Map::new();
908 params.insert("pattern".to_owned(), serde_json::json!("*.rs"));
909 params.insert(
910 "path".to_owned(),
911 serde_json::json!(dir.path().to_str().unwrap()),
912 );
913 let call = ToolCall {
914 tool_id: zeph_tools::ToolName::new("find_path"),
915 params,
916 caller_id: None,
917 };
918 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
919 assert!(result.summary.contains("foo.rs"));
920 assert!(!result.summary.contains("bar.toml"));
921 }
922
923 #[tokio::test]
924 async fn read_file_when_capability_disabled_returns_none() {
925 let local = tokio::task::LocalSet::new();
926 local
927 .run_until(async {
928 let conn = Rc::new(FakeClient {
929 content: "ignored".to_owned(),
930 });
931 let sid = acp::schema::SessionId::new("s1");
932 let (exec, handler) =
934 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
935 tokio::task::spawn_local(handler);
936
937 let mut params = serde_json::Map::new();
938 params.insert("path".to_owned(), serde_json::json!(test_path("test.txt")));
939 let call = ToolCall {
940 tool_id: zeph_tools::ToolName::new("read_file"),
941 params,
942 caller_id: None,
943 };
944 let result = exec.execute_tool_call(&call).await.unwrap();
945 assert!(result.is_none());
946 })
947 .await;
948 }
949
950 #[tokio::test]
951 async fn write_file_when_capability_disabled_returns_none() {
952 let local = tokio::task::LocalSet::new();
953 local
954 .run_until(async {
955 let conn = Rc::new(FakeClient {
956 content: String::new(),
957 });
958 let sid = acp::schema::SessionId::new("s1");
959 let (exec, handler) =
961 AcpFileExecutor::new(conn, sid, true, false, test_cwd(), None);
962 tokio::task::spawn_local(handler);
963
964 let mut params = serde_json::Map::new();
965 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
966 params.insert("content".to_owned(), serde_json::json!("data"));
967 let call = ToolCall {
968 tool_id: zeph_tools::ToolName::new("write_file"),
969 params,
970 caller_id: None,
971 };
972 let result = exec.execute_tool_call(&call).await.unwrap();
973 assert!(result.is_none());
974 })
975 .await;
976 }
977
978 #[tokio::test]
979 async fn list_directory_nonexistent_returns_error() {
980 let tmp = tempfile::tempdir().expect("tempdir");
981 let nonexistent = tmp.path().join("nonexistent_dir_zeph");
982 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
983 let exec = AcpFileExecutor {
984 session_id: acp::schema::SessionId::new("s"),
985 request_tx: tx,
986 can_read: true,
987 can_write: false,
988 cwd: tmp.path().to_path_buf(),
989 permission_gate: None,
990 };
991 let mut params = serde_json::Map::new();
992 params.insert(
993 "path".to_owned(),
994 serde_json::json!(nonexistent.to_string_lossy()),
995 );
996 let call = ToolCall {
997 tool_id: zeph_tools::ToolName::new("list_directory"),
998 params,
999 caller_id: None,
1000 };
1001 let err = exec.execute_tool_call(&call).await.unwrap_err();
1002 assert!(matches!(err, ToolError::InvalidParams { .. }));
1003 }
1004
1005 #[tokio::test]
1006 async fn list_directory_empty_dir_returns_empty_array() {
1007 let dir = tempfile::tempdir().unwrap();
1008 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1009 let exec = AcpFileExecutor {
1010 session_id: acp::schema::SessionId::new("s"),
1011 request_tx: tx,
1012 can_read: true,
1013 can_write: false,
1014 cwd: dir.path().to_path_buf(),
1015 permission_gate: None,
1016 };
1017 let mut params = serde_json::Map::new();
1018 params.insert(
1019 "path".to_owned(),
1020 serde_json::json!(dir.path().to_str().unwrap()),
1021 );
1022 let call = ToolCall {
1023 tool_id: zeph_tools::ToolName::new("list_directory"),
1024 params,
1025 caller_id: None,
1026 };
1027 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1028 assert_eq!(result.summary, "[]");
1029 }
1030
1031 #[tokio::test]
1032 async fn find_path_no_matches_returns_empty_summary() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1035 let exec = AcpFileExecutor {
1036 session_id: acp::schema::SessionId::new("s"),
1037 request_tx: tx,
1038 can_read: true,
1039 can_write: false,
1040 cwd: dir.path().to_path_buf(),
1041 permission_gate: None,
1042 };
1043 let mut params = serde_json::Map::new();
1044 params.insert("pattern".to_owned(), serde_json::json!("*.nomatch"));
1045 params.insert(
1046 "path".to_owned(),
1047 serde_json::json!(dir.path().to_str().unwrap()),
1048 );
1049 let call = ToolCall {
1050 tool_id: zeph_tools::ToolName::new("find_path"),
1051 params,
1052 caller_id: None,
1053 };
1054 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1055 assert_eq!(result.summary, "");
1056 }
1057
1058 #[tokio::test]
1059 async fn find_path_invalid_glob_returns_error() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1062 let exec = AcpFileExecutor {
1063 session_id: acp::schema::SessionId::new("s"),
1064 request_tx: tx,
1065 can_read: true,
1066 can_write: false,
1067 cwd: dir.path().to_path_buf(),
1068 permission_gate: None,
1069 };
1070 let mut params = serde_json::Map::new();
1071 params.insert("pattern".to_owned(), serde_json::json!("[invalid"));
1072 params.insert(
1073 "path".to_owned(),
1074 serde_json::json!(dir.path().to_str().unwrap()),
1075 );
1076 let call = ToolCall {
1077 tool_id: zeph_tools::ToolName::new("find_path"),
1078 params,
1079 caller_id: None,
1080 };
1081 let err = exec.execute_tool_call(&call).await.unwrap_err();
1082 assert!(matches!(err, ToolError::InvalidParams { .. }));
1083 }
1084
1085 #[tokio::test]
1086 async fn list_directory_capability_disabled_returns_none() {
1087 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1088 let exec = AcpFileExecutor {
1089 session_id: acp::schema::SessionId::new("s"),
1090 request_tx: tx,
1091 can_read: false,
1092 can_write: false,
1093 cwd: test_cwd(),
1094 permission_gate: None,
1095 };
1096 let mut params = serde_json::Map::new();
1097 params.insert("path".to_owned(), serde_json::json!(test_path("some_dir")));
1098 let call = ToolCall {
1099 tool_id: zeph_tools::ToolName::new("list_directory"),
1100 params,
1101 caller_id: None,
1102 };
1103 let result = exec.execute_tool_call(&call).await.unwrap();
1104 assert!(result.is_none());
1105 }
1106
1107 #[tokio::test]
1108 async fn find_path_capability_disabled_returns_none() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1111 let exec = AcpFileExecutor {
1112 session_id: acp::schema::SessionId::new("s"),
1113 request_tx: tx,
1114 can_read: false,
1115 can_write: false,
1116 cwd: test_cwd(),
1117 permission_gate: None,
1118 };
1119 let mut params = serde_json::Map::new();
1120 params.insert("pattern".to_owned(), serde_json::json!("*.rs"));
1121 params.insert(
1122 "path".to_owned(),
1123 serde_json::json!(dir.path().to_str().unwrap()),
1124 );
1125 let call = ToolCall {
1126 tool_id: zeph_tools::ToolName::new("find_path"),
1127 params,
1128 caller_id: None,
1129 };
1130 let result = exec.execute_tool_call(&call).await.unwrap();
1131 assert!(result.is_none());
1132 }
1133
1134 #[tokio::test]
1135 async fn find_path_traversal_in_pattern_is_rejected() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1138 let exec = AcpFileExecutor {
1139 session_id: acp::schema::SessionId::new("s"),
1140 request_tx: tx,
1141 can_read: true,
1142 can_write: false,
1143 cwd: test_cwd(),
1144 permission_gate: None,
1145 };
1146 let mut params = serde_json::Map::new();
1147 params.insert("pattern".to_owned(), serde_json::json!("../../etc/passwd"));
1148 params.insert(
1149 "path".to_owned(),
1150 serde_json::json!(dir.path().to_str().unwrap()),
1151 );
1152 let call = ToolCall {
1153 tool_id: zeph_tools::ToolName::new("find_path"),
1154 params,
1155 caller_id: None,
1156 };
1157 let err = exec.execute_tool_call(&call).await.unwrap_err();
1158 assert!(matches!(err, ToolError::SandboxViolation { .. }));
1159 }
1160
1161 #[tokio::test]
1162 async fn find_path_missing_path_param_returns_error() {
1163 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1164 let exec = AcpFileExecutor {
1165 session_id: acp::schema::SessionId::new("s"),
1166 request_tx: tx,
1167 can_read: true,
1168 can_write: false,
1169 cwd: test_cwd(),
1170 permission_gate: None,
1171 };
1172 let mut params = serde_json::Map::new();
1173 params.insert("pattern".to_owned(), serde_json::json!("*.rs"));
1174 let call = ToolCall {
1176 tool_id: zeph_tools::ToolName::new("find_path"),
1177 params,
1178 caller_id: None,
1179 };
1180 let err = exec.execute_tool_call(&call).await.unwrap_err();
1181 assert!(matches!(err, ToolError::InvalidParams { .. }));
1182 }
1183
1184 #[test]
1185 fn validate_path_rejects_traversal() {
1186 let traversal = if cfg!(windows) {
1187 "C:\\tmp\\..\\etc\\passwd"
1188 } else {
1189 "/tmp/../etc/passwd"
1190 };
1191 let err = validate_path(traversal).unwrap_err();
1192 assert!(matches!(err, ToolError::SandboxViolation { .. }));
1193 }
1194
1195 #[test]
1196 fn validate_path_accepts_relative() {
1197 let path = validate_path("relative/path.txt").unwrap();
1199 assert_eq!(path, PathBuf::from("relative/path.txt"));
1200 }
1201
1202 #[test]
1203 fn validate_path_accepts_absolute() {
1204 let path = validate_path(&test_path("safe.txt")).unwrap();
1205 assert!(path.is_absolute());
1206 }
1207
1208 #[tokio::test]
1209 async fn read_file_resolves_relative_path_against_cwd() {
1210 let local = tokio::task::LocalSet::new();
1213 local
1214 .run_until(async {
1215 let conn = Rc::new(FakeClient {
1216 content: "data".to_owned(),
1217 });
1218 let sid = acp::schema::SessionId::new("s1");
1219 let cwd = std::env::current_dir().unwrap_or_else(|_| test_cwd());
1220 let (exec, handler) =
1221 AcpFileExecutor::new(conn, sid, true, false, cwd.clone(), None);
1222 tokio::task::spawn_local(handler);
1223
1224 let mut params = serde_json::Map::new();
1225 params.insert("path".to_owned(), serde_json::json!("relative/path.txt"));
1226 let call = ToolCall {
1227 tool_id: zeph_tools::ToolName::new("read_file"),
1228 params,
1229 caller_id: None,
1230 };
1231 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1233 assert_eq!(result.summary, "data");
1234 let locations = result.locations.unwrap();
1236 assert_eq!(locations.len(), 1);
1237 assert!(
1238 std::path::Path::new(&locations[0]).is_absolute(),
1239 "location must be absolute, got: {}",
1240 locations[0]
1241 );
1242 assert!(
1243 locations[0].ends_with("relative/path.txt")
1244 || locations[0].ends_with("relative\\path.txt"),
1245 "expected path ending with relative/path.txt, got: {}",
1246 locations[0]
1247 );
1248 })
1249 .await;
1250 }
1251
1252 #[tokio::test]
1253 async fn write_file_rejects_traversal_path() {
1254 let local = tokio::task::LocalSet::new();
1255 local
1256 .run_until(async {
1257 let conn = Rc::new(FakeClient {
1258 content: String::new(),
1259 });
1260 let sid = acp::schema::SessionId::new("s1");
1261 let (exec, handler) =
1262 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
1263 tokio::task::spawn_local(handler);
1264
1265 let mut params = serde_json::Map::new();
1266 let traversal = if cfg!(windows) {
1267 "C:\\tmp\\..\\etc\\passwd"
1268 } else {
1269 "/tmp/../etc/passwd"
1270 };
1271 params.insert("path".to_owned(), serde_json::json!(traversal));
1272 params.insert("content".to_owned(), serde_json::json!("evil"));
1273 let call = ToolCall {
1274 tool_id: zeph_tools::ToolName::new("write_file"),
1275 params,
1276 caller_id: None,
1277 };
1278 let err = exec.execute_tool_call(&call).await.unwrap_err();
1279 assert!(matches!(err, ToolError::SandboxViolation { .. }));
1280 })
1281 .await;
1282 }
1283
1284 struct AlwaysRejectPermClient;
1287
1288 #[async_trait::async_trait(?Send)]
1289 impl acp::Client for AlwaysRejectPermClient {
1290 async fn request_permission(
1291 &self,
1292 _args: acp::schema::RequestPermissionRequest,
1293 ) -> acp::Result<acp::RequestPermissionResponse> {
1294 Ok(acp::RequestPermissionResponse::new(
1295 acp::schema::RequestPermissionOutcome::Selected(
1296 acp::SelectedPermissionOutcome::new("reject_once"),
1297 ),
1298 ))
1299 }
1300
1301 async fn read_text_file(
1302 &self,
1303 _args: acp::schema::ReadTextFileRequest,
1304 ) -> acp::Result<acp::ReadTextFileResponse> {
1305 Ok(acp::ReadTextFileResponse::new(String::new()))
1306 }
1307
1308 async fn write_text_file(
1309 &self,
1310 _args: acp::schema::WriteTextFileRequest,
1311 ) -> acp::Result<acp::WriteTextFileResponse> {
1312 Ok(acp::WriteTextFileResponse::new())
1313 }
1314
1315 async fn session_notification(
1316 &self,
1317 _args: acp::schema::SessionNotification,
1318 ) -> acp::Result<()> {
1319 Ok(())
1320 }
1321 }
1322
1323 struct AlwaysAllowPermClient;
1324
1325 #[async_trait::async_trait(?Send)]
1326 impl acp::Client for AlwaysAllowPermClient {
1327 async fn request_permission(
1328 &self,
1329 _args: acp::schema::RequestPermissionRequest,
1330 ) -> acp::Result<acp::RequestPermissionResponse> {
1331 Ok(acp::RequestPermissionResponse::new(
1332 acp::schema::RequestPermissionOutcome::Selected(
1333 acp::SelectedPermissionOutcome::new("allow_once"),
1334 ),
1335 ))
1336 }
1337
1338 async fn read_text_file(
1339 &self,
1340 _args: acp::schema::ReadTextFileRequest,
1341 ) -> acp::Result<acp::ReadTextFileResponse> {
1342 Ok(acp::ReadTextFileResponse::new(String::new()))
1343 }
1344
1345 async fn write_text_file(
1346 &self,
1347 _args: acp::schema::WriteTextFileRequest,
1348 ) -> acp::Result<acp::WriteTextFileResponse> {
1349 Ok(acp::WriteTextFileResponse::new())
1350 }
1351
1352 async fn session_notification(
1353 &self,
1354 _args: acp::schema::SessionNotification,
1355 ) -> acp::Result<()> {
1356 Ok(())
1357 }
1358 }
1359
1360 #[tokio::test]
1361 async fn write_file_permission_denied_returns_blocked_error() {
1362 let local = tokio::task::LocalSet::new();
1363 local
1364 .run_until(async {
1365 let conn = Rc::new(AlwaysRejectPermClient);
1366 let (gate, gate_handler) = AcpPermissionGate::new(Rc::clone(&conn), None);
1367 tokio::task::spawn_local(gate_handler);
1368 let sid = acp::schema::SessionId::new("s1");
1369 let (exec, handler) =
1370 AcpFileExecutor::new(conn, sid.clone(), false, true, test_cwd(), Some(gate));
1371 tokio::task::spawn_local(handler);
1372
1373 let mut params = serde_json::Map::new();
1374 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
1375 params.insert("content".to_owned(), serde_json::json!("data"));
1376 let call = ToolCall {
1377 tool_id: zeph_tools::ToolName::new("write_file"),
1378 params,
1379 caller_id: None,
1380 };
1381 let err = exec.execute_tool_call(&call).await.unwrap_err();
1382 assert!(matches!(err, ToolError::Blocked { .. }));
1383 })
1384 .await;
1385 }
1386
1387 #[tokio::test]
1388 async fn write_file_permission_allowed_succeeds() {
1389 let local = tokio::task::LocalSet::new();
1390 local
1391 .run_until(async {
1392 let conn = Rc::new(AlwaysAllowPermClient);
1393 let (gate, gate_handler) = AcpPermissionGate::new(Rc::clone(&conn), None);
1394 tokio::task::spawn_local(gate_handler);
1395 let sid = acp::schema::SessionId::new("s1");
1396 let (exec, handler) =
1397 AcpFileExecutor::new(conn, sid.clone(), false, true, test_cwd(), Some(gate));
1398 tokio::task::spawn_local(handler);
1399
1400 let mut params = serde_json::Map::new();
1401 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
1402 params.insert("content".to_owned(), serde_json::json!("data"));
1403 let call = ToolCall {
1404 tool_id: zeph_tools::ToolName::new("write_file"),
1405 params,
1406 caller_id: None,
1407 };
1408 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1409 assert!(result.summary.contains("out.txt"));
1410 })
1411 .await;
1412 }
1413
1414 #[tokio::test]
1415 async fn write_file_no_gate_succeeds() {
1416 let local = tokio::task::LocalSet::new();
1417 local
1418 .run_until(async {
1419 let conn = Rc::new(FakeClient {
1420 content: String::new(),
1421 });
1422 let sid = acp::schema::SessionId::new("s1");
1423 let (exec, handler) =
1424 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
1425 tokio::task::spawn_local(handler);
1426
1427 let mut params = serde_json::Map::new();
1428 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
1429 params.insert("content".to_owned(), serde_json::json!("data"));
1430 let call = ToolCall {
1431 tool_id: zeph_tools::ToolName::new("write_file"),
1432 params,
1433 caller_id: None,
1434 };
1435 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1436 assert!(result.summary.contains("out.txt"));
1437 })
1438 .await;
1439 }
1440
1441 #[test]
1444 fn validate_within_sandbox_allows_inside() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let file = dir.path().join("safe.txt");
1447 std::fs::write(&file, "ok").unwrap();
1448 assert!(validate_within_sandbox(&file, dir.path()).is_ok());
1449 }
1450
1451 #[test]
1452 fn validate_within_sandbox_rejects_escape() {
1453 let sandbox = tempfile::tempdir().unwrap();
1454 let outside = tempfile::tempdir().unwrap();
1455 let file = outside.path().join("escape.txt");
1456 std::fs::write(&file, "evil").unwrap();
1457 assert!(validate_within_sandbox(&file, sandbox.path()).is_err());
1458 }
1459
1460 #[test]
1461 fn validate_within_sandbox_nonexistent_file_parent_inside() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let new_file = dir.path().join("new_file.txt");
1464 assert!(validate_within_sandbox(&new_file, dir.path()).is_ok());
1466 }
1467
1468 #[test]
1469 fn validate_within_sandbox_nonexistent_file_parent_outside() {
1470 let sandbox = tempfile::tempdir().unwrap();
1471 let outside = tempfile::tempdir().unwrap();
1472 let new_file = outside.path().join("new_file.txt");
1473 assert!(validate_within_sandbox(&new_file, sandbox.path()).is_err());
1475 }
1476
1477 #[cfg(unix)]
1478 #[tokio::test]
1479 async fn list_directory_symlink_escape_filtered() {
1480 let sandbox = tempfile::tempdir().unwrap();
1481 let outside = tempfile::tempdir().unwrap();
1482 std::fs::write(outside.path().join("secret.txt"), "top secret").unwrap();
1483
1484 let link = sandbox.path().join("escape_link");
1486 std::os::unix::fs::symlink(outside.path().join("secret.txt"), &link).unwrap();
1487 std::fs::write(sandbox.path().join("normal.txt"), "ok").unwrap();
1489
1490 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1491 let exec = AcpFileExecutor {
1492 session_id: acp::schema::SessionId::new("s"),
1493 request_tx: tx,
1494 can_read: true,
1495 can_write: false,
1496 cwd: sandbox.path().to_path_buf(),
1497 permission_gate: None,
1498 };
1499
1500 let mut params = serde_json::Map::new();
1501 params.insert(
1502 "path".to_owned(),
1503 serde_json::json!(sandbox.path().to_str().unwrap()),
1504 );
1505 let call = ToolCall {
1506 tool_id: zeph_tools::ToolName::new("list_directory"),
1507 params,
1508 caller_id: None,
1509 };
1510 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1511 assert!(
1512 result.summary.contains("normal.txt"),
1513 "normal file must appear"
1514 );
1515 assert!(
1516 !result.summary.contains("escape_link"),
1517 "symlink escaping sandbox must be filtered out"
1518 );
1519 }
1520
1521 #[cfg(unix)]
1522 #[tokio::test]
1523 async fn find_path_symlink_escape_filtered() {
1524 let sandbox = tempfile::tempdir().unwrap();
1525 let outside = tempfile::tempdir().unwrap();
1526 std::fs::write(outside.path().join("secret.txt"), "top secret").unwrap();
1527
1528 let link = sandbox.path().join("escape_link.txt");
1530 std::os::unix::fs::symlink(outside.path().join("secret.txt"), &link).unwrap();
1531 std::fs::write(sandbox.path().join("normal.txt"), "ok").unwrap();
1532
1533 let (tx, _rx) = mpsc::unbounded_channel::<FsRequest>();
1534 let exec = AcpFileExecutor {
1535 session_id: acp::schema::SessionId::new("s"),
1536 request_tx: tx,
1537 can_read: true,
1538 can_write: false,
1539 cwd: sandbox.path().to_path_buf(),
1540 permission_gate: None,
1541 };
1542
1543 let mut params = serde_json::Map::new();
1544 params.insert("pattern".to_owned(), serde_json::json!("*.txt"));
1545 params.insert(
1546 "path".to_owned(),
1547 serde_json::json!(sandbox.path().to_str().unwrap()),
1548 );
1549 let call = ToolCall {
1550 tool_id: zeph_tools::ToolName::new("find_path"),
1551 params,
1552 caller_id: None,
1553 };
1554 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1555 assert!(
1556 result.summary.contains("normal.txt"),
1557 "normal file must appear"
1558 );
1559 assert!(
1560 !result.summary.contains("escape_link.txt"),
1561 "symlinked path escaping sandbox must be filtered out"
1562 );
1563 }
1564
1565 #[cfg(unix)]
1566 #[tokio::test]
1567 async fn read_file_via_symlink_outside_sandbox_rejected() {
1568 let local = tokio::task::LocalSet::new();
1569 local
1570 .run_until(async {
1571 let sandbox = tempfile::tempdir().unwrap();
1572 let outside = tempfile::tempdir().unwrap();
1573 std::fs::write(outside.path().join("secret.txt"), "top secret").unwrap();
1574
1575 let link = sandbox.path().join("escape_link.txt");
1576 std::os::unix::fs::symlink(outside.path().join("secret.txt"), &link).unwrap();
1577
1578 let conn = Rc::new(FakeClient {
1579 content: "should not reach".to_owned(),
1580 });
1581 let sid = acp::schema::SessionId::new("s1");
1582 let (exec, handler) = AcpFileExecutor::new(
1583 conn,
1584 sid,
1585 true,
1586 false,
1587 sandbox.path().to_path_buf(),
1588 None,
1589 );
1590 tokio::task::spawn_local(handler);
1591
1592 let mut params = serde_json::Map::new();
1593 params.insert("path".to_owned(), serde_json::json!(link.to_str().unwrap()));
1594 let call = ToolCall {
1595 tool_id: zeph_tools::ToolName::new("read_file"),
1596 params,
1597 caller_id: None,
1598 };
1599 let err = exec.execute_tool_call(&call).await.unwrap_err();
1600 assert!(matches!(err, ToolError::SandboxViolation { .. }));
1601 })
1602 .await;
1603 }
1604
1605 #[cfg(unix)]
1606 #[tokio::test]
1607 async fn write_file_via_symlink_outside_sandbox_rejected() {
1608 let local = tokio::task::LocalSet::new();
1609 local
1610 .run_until(async {
1611 let sandbox = tempfile::tempdir().unwrap();
1612 let outside = tempfile::tempdir().unwrap();
1613 std::fs::write(outside.path().join("target.txt"), "original").unwrap();
1614
1615 let link = sandbox.path().join("escape_link.txt");
1616 std::os::unix::fs::symlink(outside.path().join("target.txt"), &link).unwrap();
1617
1618 let conn = Rc::new(FakeClient {
1619 content: String::new(),
1620 });
1621 let sid = acp::schema::SessionId::new("s1");
1622 let (exec, handler) = AcpFileExecutor::new(
1623 conn,
1624 sid,
1625 false,
1626 true,
1627 sandbox.path().to_path_buf(),
1628 None,
1629 );
1630 tokio::task::spawn_local(handler);
1631
1632 let mut params = serde_json::Map::new();
1633 params.insert("path".to_owned(), serde_json::json!(link.to_str().unwrap()));
1634 params.insert("content".to_owned(), serde_json::json!("evil"));
1635 let call = ToolCall {
1636 tool_id: zeph_tools::ToolName::new("write_file"),
1637 params,
1638 caller_id: None,
1639 };
1640 let err = exec.execute_tool_call(&call).await.unwrap_err();
1641 assert!(matches!(err, ToolError::SandboxViolation { .. }));
1642 })
1643 .await;
1644 }
1645
1646 #[test]
1647 fn is_binary_detects_null_byte() {
1648 assert!(is_binary(b"hello\x00world"));
1649 assert!(!is_binary(b"plain text\nno nulls"));
1650 }
1651
1652 #[test]
1653 fn hash_content_is_deterministic() {
1654 let h1 = hash_content("hello");
1655 let h2 = hash_content("hello");
1656 let h3 = hash_content("world");
1657 assert_eq!(h1, h2);
1658 assert_ne!(h1, h3);
1659 }
1660
1661 #[test]
1662 fn compute_diff_data_captures_both_sides() {
1663 let d = compute_diff_data("old\n", "new\n", "file.txt");
1664 assert_eq!(d.file_path, "file.txt");
1665 assert_eq!(d.old_content, "old\n");
1666 assert_eq!(d.new_content, "new\n");
1667 }
1668
1669 #[tokio::test]
1670 async fn write_file_size_limit_rejected() {
1671 let local = tokio::task::LocalSet::new();
1672 local
1673 .run_until(async {
1674 let conn = Rc::new(FakeClient {
1675 content: String::new(),
1676 });
1677 let sid = acp::schema::SessionId::new("s1");
1678 let (exec, handler) =
1679 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
1680 tokio::task::spawn_local(handler);
1681
1682 let oversized = "x".repeat(MAX_WRITE_BYTES + 1);
1683 let mut params = serde_json::Map::new();
1684 params.insert("path".to_owned(), serde_json::json!(test_path("big.txt")));
1685 params.insert("content".to_owned(), serde_json::json!(oversized));
1686 let call = ToolCall {
1687 tool_id: zeph_tools::ToolName::new("write_file"),
1688 params,
1689 caller_id: None,
1690 };
1691 let err = exec.execute_tool_call(&call).await.unwrap_err();
1692 assert!(matches!(err, ToolError::InvalidParams { .. }));
1693 })
1694 .await;
1695 }
1696
1697 #[tokio::test]
1698 async fn write_file_binary_content_rejected() {
1699 let local = tokio::task::LocalSet::new();
1700 local
1701 .run_until(async {
1702 let conn = Rc::new(FakeClient {
1703 content: String::new(),
1704 });
1705 let sid = acp::schema::SessionId::new("s1");
1706 let (exec, handler) =
1707 AcpFileExecutor::new(conn, sid, false, true, test_cwd(), None);
1708 tokio::task::spawn_local(handler);
1709
1710 let mut params = serde_json::Map::new();
1712 params.insert("path".to_owned(), serde_json::json!(test_path("bin.txt")));
1713 params.insert(
1714 "content".to_owned(),
1715 serde_json::json!("hello\u{0000}world"),
1716 );
1717 let call = ToolCall {
1718 tool_id: zeph_tools::ToolName::new("write_file"),
1719 params,
1720 caller_id: None,
1721 };
1722 let err = exec.execute_tool_call(&call).await.unwrap_err();
1723 assert!(matches!(err, ToolError::InvalidParams { .. }));
1724 })
1725 .await;
1726 }
1727
1728 struct DiffApproveClient {
1729 old_content: String,
1730 }
1731
1732 #[async_trait::async_trait(?Send)]
1733 impl acp::Client for DiffApproveClient {
1734 async fn request_permission(
1735 &self,
1736 _args: acp::schema::RequestPermissionRequest,
1737 ) -> acp::Result<acp::RequestPermissionResponse> {
1738 Ok(acp::RequestPermissionResponse::new(
1739 acp::schema::RequestPermissionOutcome::Selected(
1740 acp::SelectedPermissionOutcome::new("allow_once"),
1741 ),
1742 ))
1743 }
1744
1745 async fn read_text_file(
1746 &self,
1747 _args: acp::schema::ReadTextFileRequest,
1748 ) -> acp::Result<acp::ReadTextFileResponse> {
1749 Ok(acp::ReadTextFileResponse::new(self.old_content.clone()))
1750 }
1751
1752 async fn write_text_file(
1753 &self,
1754 _args: acp::schema::WriteTextFileRequest,
1755 ) -> acp::Result<acp::WriteTextFileResponse> {
1756 Ok(acp::WriteTextFileResponse::new())
1757 }
1758
1759 async fn session_notification(
1760 &self,
1761 _args: acp::schema::SessionNotification,
1762 ) -> acp::Result<()> {
1763 Ok(())
1764 }
1765 }
1766
1767 #[tokio::test]
1768 async fn write_file_with_permission_gate_shows_diff_and_succeeds() {
1769 let local = tokio::task::LocalSet::new();
1770 local
1771 .run_until(async {
1772 let perm_conn = Rc::new(DiffApproveClient {
1773 old_content: "old content\n".into(),
1774 });
1775 let sid = acp::schema::SessionId::new("s1");
1776 let tmp_dir = tempfile::tempdir().unwrap();
1777 let perm_file = tmp_dir.path().join("perms.toml");
1778 let (gate, perm_handler) =
1779 AcpPermissionGate::new(perm_conn.clone(), Some(perm_file));
1780 tokio::task::spawn_local(perm_handler);
1781
1782 let (exec, handler) =
1783 AcpFileExecutor::new(perm_conn, sid, false, true, test_cwd(), Some(gate));
1784 tokio::task::spawn_local(handler);
1785
1786 let mut params = serde_json::Map::new();
1787 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
1788 params.insert("content".to_owned(), serde_json::json!("new content\n"));
1789 let call = ToolCall {
1790 tool_id: zeph_tools::ToolName::new("write_file"),
1791 params,
1792 caller_id: None,
1793 };
1794 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1795 assert!(result.summary.contains("wrote"));
1796 assert!(result.diff.is_some());
1797 let diff = result.diff.unwrap();
1798 assert_eq!(diff.old_content, "old content\n");
1799 assert_eq!(diff.new_content, "new content\n");
1800 })
1801 .await;
1802 }
1803
1804 struct DiffRejectClient;
1805
1806 #[async_trait::async_trait(?Send)]
1807 impl acp::Client for DiffRejectClient {
1808 async fn request_permission(
1809 &self,
1810 _args: acp::schema::RequestPermissionRequest,
1811 ) -> acp::Result<acp::RequestPermissionResponse> {
1812 Ok(acp::RequestPermissionResponse::new(
1813 acp::schema::RequestPermissionOutcome::Selected(
1814 acp::SelectedPermissionOutcome::new("reject_once"),
1815 ),
1816 ))
1817 }
1818
1819 async fn read_text_file(
1820 &self,
1821 _args: acp::schema::ReadTextFileRequest,
1822 ) -> acp::Result<acp::ReadTextFileResponse> {
1823 Ok(acp::ReadTextFileResponse::new("current\n".to_owned()))
1824 }
1825
1826 async fn write_text_file(
1827 &self,
1828 _args: acp::schema::WriteTextFileRequest,
1829 ) -> acp::Result<acp::WriteTextFileResponse> {
1830 panic!("write should not be called when diff rejected")
1831 }
1832
1833 async fn session_notification(
1834 &self,
1835 _args: acp::schema::SessionNotification,
1836 ) -> acp::Result<()> {
1837 Ok(())
1838 }
1839 }
1840
1841 #[tokio::test]
1842 async fn write_file_diff_rejected_returns_blocked() {
1843 let local = tokio::task::LocalSet::new();
1844 local
1845 .run_until(async {
1846 let perm_conn = Rc::new(DiffRejectClient);
1847 let sid = acp::schema::SessionId::new("s1");
1848 let tmp_dir = tempfile::tempdir().unwrap();
1849 let perm_file = tmp_dir.path().join("perms.toml");
1850 let (gate, perm_handler) =
1851 AcpPermissionGate::new(perm_conn.clone(), Some(perm_file));
1852 tokio::task::spawn_local(perm_handler);
1853
1854 let (exec, handler) =
1855 AcpFileExecutor::new(perm_conn, sid, false, true, test_cwd(), Some(gate));
1856 tokio::task::spawn_local(handler);
1857
1858 let mut params = serde_json::Map::new();
1859 params.insert("path".to_owned(), serde_json::json!(test_path("out.txt")));
1860 params.insert("content".to_owned(), serde_json::json!("new\n"));
1861 let call = ToolCall {
1862 tool_id: zeph_tools::ToolName::new("write_file"),
1863 params,
1864 caller_id: None,
1865 };
1866 let err = exec.execute_tool_call(&call).await.unwrap_err();
1867 assert!(matches!(err, ToolError::Blocked { .. }));
1868 })
1869 .await;
1870 }
1871
1872 struct NotFoundReadClient;
1873
1874 #[async_trait::async_trait(?Send)]
1875 impl acp::Client for NotFoundReadClient {
1876 async fn request_permission(
1877 &self,
1878 _args: acp::schema::RequestPermissionRequest,
1879 ) -> acp::Result<acp::RequestPermissionResponse> {
1880 Ok(acp::RequestPermissionResponse::new(
1881 acp::schema::RequestPermissionOutcome::Selected(
1882 acp::SelectedPermissionOutcome::new("allow_once"),
1883 ),
1884 ))
1885 }
1886
1887 async fn read_text_file(
1888 &self,
1889 _args: acp::schema::ReadTextFileRequest,
1890 ) -> acp::Result<acp::ReadTextFileResponse> {
1891 Err(acp::Error::resource_not_found(None))
1892 }
1893
1894 async fn write_text_file(
1895 &self,
1896 _args: acp::schema::WriteTextFileRequest,
1897 ) -> acp::Result<acp::WriteTextFileResponse> {
1898 Ok(acp::WriteTextFileResponse::new())
1899 }
1900
1901 async fn session_notification(
1902 &self,
1903 _args: acp::schema::SessionNotification,
1904 ) -> acp::Result<()> {
1905 Ok(())
1906 }
1907 }
1908
1909 struct ToctouClient {
1912 call_count: std::cell::Cell<usize>,
1913 }
1914
1915 #[async_trait::async_trait(?Send)]
1916 impl acp::Client for ToctouClient {
1917 async fn request_permission(
1918 &self,
1919 _args: acp::schema::RequestPermissionRequest,
1920 ) -> acp::Result<acp::RequestPermissionResponse> {
1921 Ok(acp::RequestPermissionResponse::new(
1922 acp::schema::RequestPermissionOutcome::Selected(
1923 acp::SelectedPermissionOutcome::new("allow_once"),
1924 ),
1925 ))
1926 }
1927
1928 async fn read_text_file(
1929 &self,
1930 _args: acp::schema::ReadTextFileRequest,
1931 ) -> acp::Result<acp::ReadTextFileResponse> {
1932 let n = self.call_count.get();
1933 self.call_count.set(n + 1);
1934 let content = if n == 0 {
1937 "original\n"
1938 } else {
1939 "modified by someone else\n"
1940 };
1941 Ok(acp::ReadTextFileResponse::new(content.to_owned()))
1942 }
1943
1944 async fn write_text_file(
1945 &self,
1946 _args: acp::schema::WriteTextFileRequest,
1947 ) -> acp::Result<acp::WriteTextFileResponse> {
1948 panic!("write_text_file must not be called when TOCTOU guard fires")
1949 }
1950
1951 async fn session_notification(
1952 &self,
1953 _args: acp::schema::SessionNotification,
1954 ) -> acp::Result<()> {
1955 Ok(())
1956 }
1957 }
1958
1959 #[tokio::test]
1960 async fn write_file_toctou_guard_aborts_when_file_changed() {
1961 let local = tokio::task::LocalSet::new();
1962 local
1963 .run_until(async {
1964 let perm_conn = Rc::new(ToctouClient { call_count: std::cell::Cell::new(0) });
1965 let sid = acp::schema::SessionId::new("s1");
1966 let tmp_dir = tempfile::tempdir().unwrap();
1967 let perm_file = tmp_dir.path().join("perms.toml");
1968 let (gate, perm_handler) =
1969 AcpPermissionGate::new(perm_conn.clone(), Some(perm_file));
1970 tokio::task::spawn_local(perm_handler);
1971
1972 let (exec, handler) =
1973 AcpFileExecutor::new(perm_conn, sid, false, true, test_cwd(), Some(gate));
1974 tokio::task::spawn_local(handler);
1975
1976 let mut params = serde_json::Map::new();
1977 params.insert("path".to_owned(), serde_json::json!(test_path("toctou.txt")));
1978 params.insert("content".to_owned(), serde_json::json!("my new content\n"));
1979 let call = ToolCall {
1980 tool_id: zeph_tools::ToolName::new("write_file"),
1981 params,
1982 caller_id: None,
1983 };
1984 let err = exec.execute_tool_call(&call).await.unwrap_err();
1985 assert!(
1986 matches!(err, ToolError::InvalidParams { ref message } if message.contains("file changed")),
1987 "expected TOCTOU abort error, got: {err:?}"
1988 );
1989 })
1990 .await;
1991 }
1992
1993 #[tokio::test]
1994 async fn write_new_file_with_no_old_content_succeeds() {
1995 let local = tokio::task::LocalSet::new();
1996 local
1997 .run_until(async {
1998 let perm_conn = Rc::new(NotFoundReadClient);
1999 let sid = acp::schema::SessionId::new("s1");
2000 let tmp_dir = tempfile::tempdir().unwrap();
2001 let perm_file = tmp_dir.path().join("perms.toml");
2002 let (gate, perm_handler) =
2003 AcpPermissionGate::new(perm_conn.clone(), Some(perm_file));
2004 tokio::task::spawn_local(perm_handler);
2005
2006 let (exec, handler) =
2007 AcpFileExecutor::new(perm_conn, sid, false, true, test_cwd(), Some(gate));
2008 tokio::task::spawn_local(handler);
2009
2010 let mut params = serde_json::Map::new();
2011 params.insert("path".to_owned(), serde_json::json!(test_path("new.txt")));
2012 params.insert("content".to_owned(), serde_json::json!("hello\n"));
2013 let call = ToolCall {
2014 tool_id: zeph_tools::ToolName::new("write_file"),
2015 params,
2016 caller_id: None,
2017 };
2018 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
2019 assert!(result.summary.contains("wrote"));
2020 let diff = result.diff.unwrap();
2021 assert_eq!(diff.old_content, "");
2022 assert_eq!(diff.new_content, "hello\n");
2023 })
2024 .await;
2025 }
2026}