Skip to main content

zeph_acp/
fs.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! IDE-proxied filesystem executor via ACP `fs/*` methods.
5//!
6//! When the IDE advertises `fs.readTextFile` and/or `fs.writeTextFile` during
7//! the ACP `initialize()` handshake, the agent can delegate file I/O to the IDE
8//! rather than performing it directly. This allows the IDE to apply its own
9//! access controls, open unsaved buffers, and show diff previews.
10//!
11//! # Security
12//!
13//! Write operations enforce a 10 MiB content limit and binary file detection
14//! (null byte check) before forwarding to the IDE. An optional
15//! [`AcpPermissionGate`] can request explicit user confirmation for writes.
16
17use 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; // REQ-P31-5: 10 MiB
35
36fn is_binary(content: &[u8]) -> bool {
37    content.contains(&0) // REQ-P31-6: null byte detection
38}
39
40// Same-process comparison only: `DefaultHasher` is not stable across processes or versions.
41fn 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/// IDE-proxied file system executor.
77///
78/// Routes `read_file` / `write_file` tool calls to the IDE via ACP `fs/*` methods.
79/// Only constructed when the IDE advertises `fs.readTextFile` or `fs.writeTextFile`
80/// capability.
81#[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    /// Create the executor and its background handler future.
93    ///
94    /// `can_read` / `can_write` gate which tool definitions are advertised.
95    /// `permission_gate` is used to request user confirmation before writing files.
96    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    /// Resolve a potentially relative path to an absolute path
121    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    /// Directory to search in. Must be an absolute path within the project sandbox.
197    path: String,
198    /// Glob pattern to match file names (e.g. `*.rs`, `config*.toml`).
199    pattern: String,
200}
201
202/// Verify that `resolved` is contained within `sandbox` after symlink resolution.
203///
204/// For existing paths: canonicalize and check prefix.
205/// For non-existent paths (e.g. new files): canonicalize the parent directory instead.
206///
207/// # Errors
208///
209/// Returns `ToolError::SandboxViolation` if the path escapes the sandbox or the parent
210/// directory cannot be canonicalized.
211fn 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            // Walk up ancestors to find the first existing directory.
227            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    // Reject obvious traversal components (agent shouldn't try to escape workspace).
261    if path.components().any(|c| c.as_os_str() == "..") {
262        return Err(ToolError::SandboxViolation {
263            path: raw.to_owned(),
264        });
265    }
266    // Symlink resolution is intentionally delegated to the IDE: the agent sends the path
267    // as-is via the ACP protocol and the IDE enforces its own sandbox (workspace root,
268    // read-only mounts, etc.). The agent trusts the IDE's file-system sandbox boundary.
269    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        // REQ-P31-1: write_file requires a permission gate (diff preview must have an approver).
303        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(&params.path)?;
320                let resolved = self.resolve_path(&path);
321                // Defense-in-depth: reject paths that escape cwd. The IDE enforces its own
322                // sandbox; we use parent-dir canonicalization to handle non-existent paths
323                // and resolve symlinks in the directory component.
324                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(&params)
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        // REQ-P31-5: size check before any work
380        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        // REQ-P31-6: binary detection on new content
386        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(&params.path)?;
392        let resolved = self.resolve_path(&path);
393        validate_within_sandbox(&resolved, &self.cwd)?;
394
395        // Read current file for diff (None if new file).
396        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        // REQ-P31-6: binary detection on existing content
404        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        // Hash old content for TOCTOU guard (REQ-P31-3)
413        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        // REQ-P31-2: show diff preview and require approval
423        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        // REQ-P31-3: TOCTOU guard — re-read and compare hash
445        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            &params.content,
460            &params.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(&params.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            // Use symlink_metadata to avoid following symlinks outside the sandbox.
497            let meta = entry
498                .path()
499                .symlink_metadata()
500                .map_err(|e| ToolError::InvalidParams {
501                    message: format!("metadata error: {e}"),
502                })?;
503            // Skip symlinks whose canonical target escapes the sandbox.
504            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(&params.path)?;
540        let base = self.resolve_path(&path);
541
542        // Reject traversal components in the pattern to prevent escaping the base directory.
543        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                // Skip paths that escape the sandbox via symlinks.
565                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// Tests disabled pending ACP 0.11 test infrastructure update (issue #3267 PR3)
646#[cfg(any())] // ACP 0.10 tests disabled — pending PR3 test infrastructure
647mod 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    /// Minimal client for constructing `AcpPermissionGate` in tests that don't need real perms.
663    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        // REQ-P31-1: write_file not advertised without permission gate.
827        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                // can_read = false
933                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                // can_write = false
960                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        // no "path" key — should error, not default to "."
1175        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        // Relative paths are now accepted; resolve_path joins them with cwd.
1198        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        // Relative paths are joined with cwd; the FakeClient mirrors the path back
1211        // so we verify the resolved absolute path is forwarded correctly.
1212        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                // Should succeed: relative path is resolved to cwd/relative/path.txt.
1232                let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
1233                assert_eq!(result.summary, "data");
1234                // locations must carry the absolute resolved path
1235                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    // --- P0.1: permission gate tests ---
1285
1286    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    // --- P0.2: symlink sandbox tests ---
1442
1443    #[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        // File does not exist, but parent (dir) is inside sandbox.
1465        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        // File does not exist, parent is outside sandbox.
1474        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        // Create a symlink inside sandbox pointing outside.
1485        let link = sandbox.path().join("escape_link");
1486        std::os::unix::fs::symlink(outside.path().join("secret.txt"), &link).unwrap();
1487        // Create a normal file inside sandbox.
1488        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        // Create a symlink inside sandbox pointing outside.
1529        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                // Embed a null byte to trigger binary detection.
1711                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    /// Simulates a file being modified externally between the diff preview read and the TOCTOU
1910    /// re-read. Returns different content on each call to `read_text_file`.
1911    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            // First read (diff preview): original content.
1935            // Second read (TOCTOU guard): externally modified content.
1936            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}