1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! Write File Tool
//!
//! Allows writing content to files on the filesystem.
use super::error::{Result, ToolError, validate_path_safety};
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::fs;
/// Write file tool
pub struct WriteTool;
#[derive(Debug, Deserialize, Serialize)]
struct WriteInput {
/// Path to the file to write
path: String,
/// Content to write to the file
content: String,
/// Whether to create parent directories if they don't exist
#[serde(default)]
create_dirs: bool,
}
#[async_trait]
impl Tool for WriteTool {
fn name(&self) -> &str {
"write_file"
}
fn description(&self) -> &str {
"Write content to a file on the filesystem. Creates the file if it doesn't exist, overwrites if it does."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to write (absolute or relative to working directory)"
},
"content": {
"type": "string",
"description": "Content to write to the file"
},
"create_dirs": {
"type": "boolean",
"description": "Whether to create parent directories if they don't exist (default: false)",
"default": false
}
},
"required": ["path", "content"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::WriteFiles,
ToolCapability::SystemModification,
]
}
fn requires_approval(&self) -> bool {
true // Writing files requires approval
}
fn validate_input(&self, input: &Value) -> Result<()> {
let _: WriteInput = serde_json::from_value(input.clone())
.map_err(|e| ToolError::InvalidInput(format!("Invalid input: {}", e)))?;
Ok(())
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let input: WriteInput = serde_json::from_value(input)?;
// Resolve path (tilde expansion + absolute/relative resolution).
let path = super::error::resolve_tool_path(&input.path, &context.working_dir());
// Brain-file guardrail (issue #91): protected brain files
// (SOUL.md, MEMORY.md, USER.md, etc.) must go through
// `write_opencrabs_file`, which enforces append-only,
// dedup-aware shrink, and `.bak` snapshots. Generic write_file
// does none of those, so reject the call before any bytes hit
// disk and tell the agent to switch tools.
if super::brain_file_safety::is_protected_path(&path) {
return Ok(ToolResult::error(format!(
"Refusing to write protected brain file '{}' with generic write_file. \
Use the `write_opencrabs_file` tool instead. It enforces append-only \
writes, dedup-aware shrinking, and saves a `.bak` snapshot before every \
change.",
path.display()
)));
}
// Create parent directories if requested (before path validation)
if input.create_dirs
&& let Some(parent) = path.parent()
{
// Validate parent path is within working directory
let canonical_wd = context.working_dir().canonicalize().map_err(|e| {
ToolError::Internal(format!("Failed to canonicalize working directory: {}", e))
})?;
// If parent exists, check it's within bounds
if parent.exists() {
let canonical_parent = parent.canonicalize().map_err(|e| {
ToolError::InvalidInput(format!("Failed to resolve parent path: {}", e))
})?;
if !canonical_parent.starts_with(&canonical_wd) {
return Ok(ToolResult::error(format!(
"Access denied: Path '{}' is outside the working directory",
input.path
)));
}
}
fs::create_dir_all(parent).await.map_err(ToolError::Io)?;
}
// Resolve path (relative paths resolve against working directory)
let path = match validate_path_safety(&input.path, &context.working_dir()) {
Ok(p) => p,
Err(ToolError::InvalidInput(msg))
if msg.contains("Parent directory does not exist") =>
{
// For write operations, give a helpful error about create_dirs
let resolved = std::path::PathBuf::from(&input.path);
if let Some(parent) = resolved.parent() {
return Ok(ToolResult::error(format!(
"Parent directory does not exist: {}. Use create_dirs: true to create it.",
parent.display()
)));
}
return Ok(ToolResult::error(msg));
}
Err(ToolError::InvalidInput(msg)) => {
return Ok(ToolResult::error(format!("Invalid path: {}", msg)));
}
Err(e) => return Err(e),
};
// Check if parent directory exists (safety check after validation)
if let Some(parent) = path.parent()
&& !parent.exists()
{
return Ok(ToolResult::error(format!(
"Parent directory does not exist: {}. Use create_dirs: true to create it.",
parent.display()
)));
}
// Never let a raw write corrupt the OpenCrabs config.toml/keys.toml (#713):
// a broken write there takes down all API keys and the bot token. Deny it,
// tell the agent why, and leave the file untouched.
if let Err(msg) = crate::config::guard::deny_if_would_break(&path, &input.content) {
return Ok(ToolResult::error(msg));
}
// Several agents share this working directory by design, and this tool
// replaces the file wholesale with content composed from a read in an
// EARLIER call. If the file moved since then, writing it destroys the
// other agent's change with nothing reported. Refuse and let the agent
// re-read — it can do that; it cannot detect a silent clobber (#954).
let on_disk = fs::read_to_string(&path).await.ok();
if super::file_versions::is_stale_write(context.session_id, &path, on_disk.as_deref()) {
tracing::warn!(
"write_file refused for {}: changed since this session read it — \
concurrent agents in one working directory",
path.display()
);
return Ok(ToolResult::error(super::file_versions::refusal_message(
&path,
)));
}
// One writer at a time on this path (#1153). Advisory, held only
// across the write; a contended write proceeds and says so rather
// than refusing, since a blocked save is worse than an interleave.
let write_lock = super::path_lock::acquire(&path);
let contended = write_lock.as_ref().is_some_and(|l| !l.is_held());
// Write the file
fs::write(&path, &input.content)
.await
.map_err(ToolError::Io)?;
drop(write_lock);
// The session now knows the file as what it just wrote, so its next
// write is not mistaken for a stale one.
super::file_versions::record(context.session_id, &path, &input.content);
// Track file in session (fire and forget, path-only)
if let Some(ref sc) = context.service_context {
let fs = crate::services::FileService::new(sc.clone());
let _ = fs
.get_or_create_file(context.session_id, path.clone(), None)
.await;
}
let mut message = format!(
"Successfully wrote {} bytes to {}",
input.content.len(),
path.display()
);
// An overlapping write is reported rather than swallowed.
if contended {
message.push_str(&super::path_lock::contention_notice(&path));
}
Ok(ToolResult::success(message)
.with_metadata("path".to_string(), path.display().to_string())
.with_metadata("bytes".to_string(), input.content.len().to_string()))
}
}