Skip to main content

agentzero_tools/
write_file.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::{Component, Path, PathBuf};
6use tokio::fs;
7
8const DEFAULT_MAX_WRITE_BYTES: u64 = 64 * 1024;
9
10#[derive(Debug, Clone)]
11pub struct WriteFilePolicy {
12    pub allowed_root: PathBuf,
13    pub max_write_bytes: u64,
14}
15
16impl WriteFilePolicy {
17    pub fn default_for_root(allowed_root: PathBuf) -> Self {
18        Self {
19            allowed_root,
20            max_write_bytes: DEFAULT_MAX_WRITE_BYTES,
21        }
22    }
23}
24
25#[derive(Debug, Deserialize)]
26struct WriteFileInput {
27    path: String,
28    content: String,
29    #[serde(default)]
30    overwrite: bool,
31    #[serde(default)]
32    dry_run: bool,
33}
34
35pub struct WriteFileTool {
36    allowed_root: PathBuf,
37    max_write_bytes: u64,
38}
39
40impl WriteFileTool {
41    pub fn new(policy: WriteFilePolicy) -> Self {
42        Self {
43            allowed_root: policy.allowed_root,
44            max_write_bytes: policy.max_write_bytes,
45        }
46    }
47
48    fn parse_input(input: &str) -> anyhow::Result<WriteFileInput> {
49        serde_json::from_str(input).context(
50            "write_file expects JSON input: {\"path\",\"content\",\"overwrite\",\"dry_run\"}",
51        )
52    }
53
54    fn resolve_destination(
55        &self,
56        input_path: &str,
57        workspace_root: &str,
58    ) -> anyhow::Result<PathBuf> {
59        if input_path.trim().is_empty() {
60            return Err(anyhow!("write_file.path is required"));
61        }
62        let relative = Path::new(input_path);
63        if relative.is_absolute() {
64            return Err(anyhow!("absolute paths are not allowed"));
65        }
66        if relative
67            .components()
68            .any(|c| matches!(c, Component::ParentDir))
69        {
70            return Err(anyhow!("path traversal is not allowed"));
71        }
72
73        let joined = Path::new(workspace_root).join(relative);
74        let file_name = joined
75            .file_name()
76            .ok_or_else(|| anyhow!("write_file.path must target a file"))?
77            .to_os_string();
78        let parent = joined
79            .parent()
80            .ok_or_else(|| anyhow!("write_file.path must have a parent directory"))?;
81        let canonical_parent = parent
82            .canonicalize()
83            .with_context(|| format!("unable to resolve write target parent: {input_path}"))?;
84        let canonical_allowed_root = self
85            .allowed_root
86            .canonicalize()
87            .context("unable to resolve allowed root")?;
88        if !canonical_parent.starts_with(&canonical_allowed_root) {
89            return Err(anyhow!("path is outside allowed root"));
90        }
91        Ok(canonical_parent.join(file_name))
92    }
93}
94
95#[async_trait]
96impl Tool for WriteFileTool {
97    fn name(&self) -> &'static str {
98        "write_file"
99    }
100
101    fn description(&self) -> &'static str {
102        "Write content to a file, creating it if it does not exist or overwriting if overwrite=true. Path must be within the workspace root."
103    }
104
105    fn input_schema(&self) -> Option<serde_json::Value> {
106        Some(serde_json::json!({
107            "type": "object",
108            "properties": {
109                "path": {
110                    "type": "string",
111                    "description": "Relative path to the file to write"
112                },
113                "content": {
114                    "type": "string",
115                    "description": "Text content to write to the file"
116                },
117                "overwrite": {
118                    "type": "boolean",
119                    "description": "Set to true to overwrite an existing file"
120                },
121                "dry_run": {
122                    "type": "boolean",
123                    "description": "If true, report what would happen without writing"
124                }
125            },
126            "required": ["path", "content"]
127        }))
128    }
129
130    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
131        let request = Self::parse_input(input)?;
132        let destination = self.resolve_destination(&request.path, &ctx.workspace_root)?;
133
134        // B7: Hard-link guard — refuse overwriting multiply-linked files.
135        crate::autonomy::AutonomyPolicy::check_hard_links(&destination.to_string_lossy())?;
136
137        // B7: Sensitive file detection — block unless explicitly allowed.
138        if !ctx.allow_sensitive_file_writes
139            && crate::autonomy::is_sensitive_path(&destination.to_string_lossy())
140        {
141            return Err(anyhow!(
142                "refusing to write sensitive file: {}",
143                destination.display()
144            ));
145        }
146
147        let bytes = request.content.as_bytes();
148        if bytes.len() as u64 > self.max_write_bytes {
149            return Err(anyhow!(
150                "content is too large (max {} bytes)",
151                self.max_write_bytes
152            ));
153        }
154
155        let exists = fs::try_exists(&destination)
156            .await
157            .context("failed to check destination path")?;
158        if exists && !request.overwrite {
159            return Err(anyhow!(
160                "destination already exists; set overwrite=true to replace"
161            ));
162        }
163
164        if request.dry_run {
165            return Ok(ToolResult {
166                output: format!(
167                    "dry_run=true path={} bytes={} overwrite={}",
168                    destination.display(),
169                    bytes.len(),
170                    request.overwrite
171                ),
172            });
173        }
174
175        fs::write(&destination, bytes)
176            .await
177            .with_context(|| format!("failed to write file: {}", destination.display()))?;
178
179        Ok(ToolResult {
180            output: format!(
181                "dry_run=false path={} bytes={} overwrite={}",
182                destination.display(),
183                bytes.len(),
184                request.overwrite
185            ),
186        })
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::{WriteFilePolicy, WriteFileTool};
193    use agentzero_core::{Tool, ToolContext};
194    use std::fs;
195    use std::path::PathBuf;
196    use std::sync::atomic::{AtomicU64, Ordering};
197    use std::time::{SystemTime, UNIX_EPOCH};
198
199    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
200
201    fn temp_dir() -> PathBuf {
202        let nanos = SystemTime::now()
203            .duration_since(UNIX_EPOCH)
204            .expect("clock should be after unix epoch")
205            .as_nanos();
206        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
207        let dir = std::env::temp_dir().join(format!(
208            "agentzero-write-file-{}-{nanos}-{seq}",
209            std::process::id()
210        ));
211        fs::create_dir_all(&dir).expect("temp dir should be created");
212        dir
213    }
214
215    #[tokio::test]
216    async fn write_file_writes_inside_allowed_root() {
217        let dir = temp_dir();
218        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
219        let result = tool
220            .execute(
221                r#"{"path":"note.txt","content":"hello","overwrite":false,"dry_run":false}"#,
222                &ToolContext::new(dir.to_string_lossy().to_string()),
223            )
224            .await
225            .expect("write_file should succeed");
226
227        assert!(result.output.contains("dry_run=false"));
228        let content = fs::read_to_string(dir.join("note.txt")).expect("written file should exist");
229        assert_eq!(content, "hello");
230        fs::remove_dir_all(dir).expect("temp dir should be removed");
231    }
232
233    #[tokio::test]
234    async fn write_file_dry_run_does_not_write() {
235        let dir = temp_dir();
236        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
237        let result = tool
238            .execute(
239                r#"{"path":"note.txt","content":"hello","overwrite":false,"dry_run":true}"#,
240                &ToolContext::new(dir.to_string_lossy().to_string()),
241            )
242            .await
243            .expect("dry run should succeed");
244
245        assert!(result.output.contains("dry_run=true"));
246        assert!(!dir.join("note.txt").exists());
247        fs::remove_dir_all(dir).expect("temp dir should be removed");
248    }
249
250    #[tokio::test]
251    async fn write_file_rejects_existing_file_when_overwrite_false() {
252        let dir = temp_dir();
253        let target = dir.join("note.txt");
254        fs::write(&target, "old").expect("seed file should be written");
255        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
256        let result = tool
257            .execute(
258                r#"{"path":"note.txt","content":"new","overwrite":false,"dry_run":false}"#,
259                &ToolContext::new(dir.to_string_lossy().to_string()),
260            )
261            .await;
262
263        assert!(result.is_err());
264        assert!(result
265            .expect_err("overwrite=false should fail when file exists")
266            .to_string()
267            .contains("overwrite=true"));
268        fs::remove_dir_all(dir).expect("temp dir should be removed");
269    }
270
271    #[tokio::test]
272    async fn write_file_allows_overwrite_when_enabled() {
273        let dir = temp_dir();
274        let target = dir.join("note.txt");
275        fs::write(&target, "old").expect("seed file should be written");
276        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
277        tool.execute(
278            r#"{"path":"note.txt","content":"new","overwrite":true,"dry_run":false}"#,
279            &ToolContext::new(dir.to_string_lossy().to_string()),
280        )
281        .await
282        .expect("overwrite=true should succeed");
283
284        let content = fs::read_to_string(&target).expect("target should be readable");
285        assert_eq!(content, "new");
286        fs::remove_dir_all(dir).expect("temp dir should be removed");
287    }
288
289    #[tokio::test]
290    async fn write_file_rejects_path_outside_allowed_root() {
291        let dir = temp_dir();
292        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
293        let result = tool
294            .execute(
295                r#"{"path":"../escape.txt","content":"x","overwrite":false,"dry_run":false}"#,
296                &ToolContext::new(dir.to_string_lossy().to_string()),
297            )
298            .await;
299
300        assert!(result.is_err());
301        assert!(result
302            .expect_err("traversal should be denied")
303            .to_string()
304            .contains("path traversal is not allowed"));
305        fs::remove_dir_all(dir).expect("temp dir should be removed");
306    }
307
308    // B7: Hard-link guard tests
309
310    #[cfg(unix)]
311    #[tokio::test]
312    async fn write_file_rejects_hard_linked_file() {
313        let dir = temp_dir();
314        let original = dir.join("original.txt");
315        fs::write(&original, "old").expect("original file should be written");
316        let link = dir.join("hardlink.txt");
317        fs::hard_link(&original, &link).expect("hard link should be created");
318
319        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
320        let result = tool
321            .execute(
322                r#"{"path":"hardlink.txt","content":"new","overwrite":true,"dry_run":false}"#,
323                &ToolContext::new(dir.to_string_lossy().to_string()),
324            )
325            .await;
326
327        assert!(result.is_err());
328        assert!(result
329            .expect_err("hard-linked file should be rejected")
330            .to_string()
331            .contains("hard link"));
332        fs::remove_dir_all(dir).expect("temp dir should be removed");
333    }
334
335    // B7: Sensitive file detection tests
336
337    #[tokio::test]
338    async fn write_file_blocks_sensitive_path() {
339        let dir = temp_dir();
340        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
341        let result = tool
342            .execute(
343                r#"{"path":".env","content":"SECRET=x","overwrite":false,"dry_run":false}"#,
344                &ToolContext::new(dir.to_string_lossy().to_string()),
345            )
346            .await;
347
348        assert!(result.is_err());
349        assert!(result
350            .expect_err("sensitive file should be blocked")
351            .to_string()
352            .contains("refusing to write sensitive file"));
353        fs::remove_dir_all(dir).expect("temp dir should be removed");
354    }
355
356    #[tokio::test]
357    async fn write_file_allows_sensitive_path_when_configured() {
358        let dir = temp_dir();
359        let tool = WriteFileTool::new(WriteFilePolicy::default_for_root(dir.clone()));
360        let mut ctx = ToolContext::new(dir.to_string_lossy().to_string());
361        ctx.allow_sensitive_file_writes = true;
362        let result = tool
363            .execute(
364                r#"{"path":".env","content":"SECRET=x","overwrite":false,"dry_run":false}"#,
365                &ctx,
366            )
367            .await
368            .expect("sensitive file should be allowed when configured");
369
370        assert!(result.output.contains("dry_run=false"));
371        let content = fs::read_to_string(dir.join(".env")).expect("written file should exist");
372        assert_eq!(content, "SECRET=x");
373        fs::remove_dir_all(dir).expect("temp dir should be removed");
374    }
375
376    #[tokio::test]
377    async fn write_file_rejects_content_larger_than_policy_limit() {
378        let dir = temp_dir();
379        let tool = WriteFileTool::new(WriteFilePolicy {
380            allowed_root: dir.clone(),
381            max_write_bytes: 4,
382        });
383        let result = tool
384            .execute(
385                r#"{"path":"note.txt","content":"12345","overwrite":false,"dry_run":false}"#,
386                &ToolContext::new(dir.to_string_lossy().to_string()),
387            )
388            .await;
389
390        assert!(result.is_err());
391        assert!(result
392            .expect_err("oversized content should fail")
393            .to_string()
394            .contains("content is too large"));
395        fs::remove_dir_all(dir).expect("temp dir should be removed");
396    }
397}