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 crate::autonomy::AutonomyPolicy::check_hard_links(&destination.to_string_lossy())?;
136
137 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 #[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 #[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}