1use std::fs::{self, OpenOptions, Permissions};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use serde_json::{json, Value};
27use sha2::{Digest, Sha256};
28
29use crate::formal_ai_policy::{parent_of, workspace_path, PolicyContext, Verification};
30
31pub const FILE_TOOL_NAMES: [&str; 3] = ["read", "edit", "write"];
32
33fn path_schema() -> Value {
34 json!({ "type": "string", "description": "File path inside the working directory" })
35}
36
37pub fn file_tool(name: &str) -> Value {
39 let (description, properties, required) = match name {
40 "read" => (
41 "Read the exact text of one file in the working directory.",
42 json!({ "path": path_schema() }),
43 json!(["path"]),
44 ),
45 "edit" => (
46 "Replace the literal text old_string with new_string in one file read before; old_string must occur exactly once unless replace_all is true.",
47 json!({
48 "path": path_schema(),
49 "old_string": { "type": "string" },
50 "new_string": { "type": "string" },
51 "replace_all": { "type": "boolean" },
52 }),
53 json!(["path", "old_string", "new_string"]),
54 ),
55 _ => (
56 "Write the whole text of one file: a file read before, or a new file in a directory listed before.",
57 json!({ "path": path_schema(), "content": { "type": "string" } }),
58 json!(["path", "content"]),
59 ),
60 };
61 json!({
62 "type": "function",
63 "function": {
64 "name": name,
65 "description": description,
66 "parameters": { "type": "object", "properties": properties, "required": required },
67 },
68 })
69}
70
71#[derive(Debug, Clone, PartialEq, Default)]
73pub struct FileArgs {
74 pub path: String,
75 pub old_string: Option<String>,
76 pub new_string: Option<String>,
77 pub replace_all: bool,
78 pub content: Option<String>,
79}
80
81const PATH: &[&str] = &["path", "filePath", "file_path"];
83const OLD: &[&str] = &["old_string", "oldString", "old_str", "old"];
84const NEW: &[&str] = &["new_string", "newString", "new_str", "new"];
85const REPLACE_ALL: &[&str] = &["replace_all", "replaceAll"];
86const CONTENT: &[&str] = &["content"];
87
88fn fields(tool: &str) -> &'static [&'static [&'static str]] {
89 match tool {
90 "read" => &[PATH],
91 "edit" => &[PATH, OLD, NEW, REPLACE_ALL],
92 _ => &[PATH, CONTENT],
93 }
94}
95
96fn required(tool: &str) -> &'static [&'static [&'static str]] {
97 match tool {
98 "read" => &[PATH],
99 "edit" => &[PATH, OLD, NEW],
100 _ => &[PATH, CONTENT],
101 }
102}
103
104pub fn normalize_file_args(tool: &str, args: &Value) -> Result<FileArgs, String> {
106 let Some(object) = args.as_object() else {
107 return Err(format!("{} arguments are not a JSON object", tool));
108 };
109 let mut found: Vec<(&str, Value)> = Vec::new();
110 for aliases in fields(tool) {
111 let boolean = *aliases == REPLACE_ALL;
112 let kind = if boolean { "boolean" } else { "string" };
113 let values: Vec<&Value> = aliases.iter().filter_map(|key| object.get(*key)).collect();
114 if values
115 .iter()
116 .any(|value| (boolean && !value.is_boolean()) || (!boolean && !value.is_string()))
117 {
118 return Err(format!(
119 "{} argument {} must be a {}",
120 tool, aliases[0], kind
121 ));
122 }
123 if values.iter().any(|value| *value != values[0]) {
124 return Err(format!(
125 "{} received conflicting {} values",
126 tool, aliases[0]
127 ));
128 }
129 if let Some(value) = values.first() {
130 found.push((aliases[0], (*value).clone()));
131 }
132 }
133 let missing: Vec<&str> = required(tool)
134 .iter()
135 .map(|aliases| aliases[0])
136 .filter(|name| !found.iter().any(|(key, _)| key == name))
137 .collect();
138 if !missing.is_empty() {
139 return Err(format!("{} needs {}", tool, missing.join(", ")));
140 }
141 let text = |name: &str| {
142 found
143 .iter()
144 .find(|(key, _)| *key == name)
145 .and_then(|(_, value)| value.as_str())
146 .map(str::to_string)
147 };
148 let result = FileArgs {
149 path: text("path").unwrap_or_default(),
150 old_string: text("old_string"),
151 new_string: text("new_string"),
152 replace_all: found
153 .iter()
154 .find(|(key, _)| *key == "replace_all")
155 .and_then(|(_, value)| value.as_bool())
156 .unwrap_or(false),
157 content: text("content"),
158 };
159 if result.path.trim().is_empty() || result.path.contains('\0') {
160 return Err(format!("{} needs a non-empty path", tool));
161 }
162 Ok(result)
163}
164
165pub fn describe_file_call(tool: &str, args: &FileArgs) -> String {
167 format!("{} {}", tool, args.path)
168}
169
170#[derive(Debug, Clone, PartialEq, Default)]
172pub struct FileDecision {
173 pub allowed: bool,
174 pub reason: Option<String>,
175 pub kind: Option<&'static str>,
177 pub target: Option<String>,
178}
179
180fn refuse(reason: String) -> FileDecision {
181 FileDecision {
182 allowed: false,
183 reason: Some(reason),
184 ..FileDecision::default()
185 }
186}
187
188fn root_of(cwd: &Path) -> PathBuf {
189 fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
190}
191
192pub fn evaluate_file_call(tool: &str, args: &FileArgs, context: &PolicyContext) -> FileDecision {
194 let kind = if tool == "read" { "read" } else { "write" };
195 if kind == "write" && context.effects != "workspace-write" {
196 return refuse(format!(
197 "\"{}\" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)",
198 tool
199 ));
200 }
201 let outside = || {
202 refuse(format!(
203 "\"{}\" is outside the working directory",
204 args.path
205 ))
206 };
207 if args.path.starts_with('~') {
208 return outside();
209 }
210 let Some(target) = workspace_path(&context.cwd, &args.path) else {
211 return outside();
212 };
213 if target == "." {
214 return refuse(format!(
215 "\"{}\" is the working directory, not a file",
216 args.path
217 ));
218 }
219 if kind == "write" {
220 let exists = root_of(&context.cwd).join(&target).exists();
221 let observed = context.observed.contains(&target)
222 || (tool == "write" && !exists && context.observed_dirs.contains(&parent_of(&target)));
223 if !observed {
224 return refuse(format!(
225 "target \"{}\" was not {} before the change",
226 target,
227 if tool == "edit" { "read" } else { "observed" }
228 ));
229 }
230 }
231 FileDecision {
232 allowed: true,
233 reason: None,
234 kind: Some(kind),
235 target: Some(target),
236 }
237}
238
239pub fn sha256_hex(data: &[u8]) -> String {
241 Sha256::digest(data)
242 .iter()
243 .map(|byte| format!("{:02x}", byte))
244 .collect()
245}
246
247struct FileText {
248 text: String,
249 bytes: Vec<u8>,
250 permissions: Permissions,
251}
252
253fn read_text(file: &Path, max_file_bytes: u64) -> Result<FileText, String> {
255 let meta = fs::metadata(file).map_err(|error| {
256 if error.kind() == std::io::ErrorKind::NotFound {
257 "no such file".to_string()
258 } else {
259 format!("cannot read the file ({})", error)
260 }
261 })?;
262 if meta.is_dir() {
263 return Err("is a directory, not a file".to_string());
264 }
265 if !meta.is_file() {
266 return Err("is not a regular file".to_string());
267 }
268 let too_large = |size: u64| {
269 format!(
270 "has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}",
271 size, max_file_bytes
272 )
273 };
274 if meta.len() > max_file_bytes {
275 return Err(too_large(meta.len()));
276 }
277 let bytes = fs::read(file).map_err(|error| format!("cannot read the file ({})", error))?;
278 if bytes.len() as u64 > max_file_bytes {
279 return Err(too_large(bytes.len() as u64));
280 }
281 match std::str::from_utf8(&bytes) {
282 Ok(text) if !text.contains('\0') => Ok(FileText {
283 text: text.to_string(),
284 bytes: bytes.clone(),
285 permissions: meta.permissions(),
286 }),
287 _ => Err("is not UTF-8 text".to_string()),
288 }
289}
290
291fn write_atomically(
293 file: &Path,
294 content: &[u8],
295 permissions: Option<Permissions>,
296) -> std::io::Result<()> {
297 let name = file
298 .file_name()
299 .map(|n| n.to_string_lossy().to_string())
300 .unwrap_or_default();
301 let temporary = file.with_file_name(format!(
302 ".{}.{}.{}.start-tmp",
303 name,
304 std::process::id(),
305 uuid::Uuid::new_v4()
306 ));
307 let result = (|| {
308 let mut handle = OpenOptions::new()
309 .write(true)
310 .create_new(true)
311 .open(&temporary)?;
312 handle.write_all(content)?;
313 if let Some(permissions) = permissions {
314 fs::set_permissions(&temporary, permissions)?;
315 }
316 handle.sync_all()?;
317 drop(handle);
318 fs::rename(&temporary, file)
319 })();
320 let _ = fs::remove_file(&temporary);
321 result
322}
323
324#[derive(Debug, Clone, PartialEq, Default)]
326pub struct FileReceipt {
327 pub exit_code: i32,
328 pub result: String,
331 pub text: Option<String>,
333 pub error: Option<String>,
334 pub bytes: Option<u64>,
335 pub sha256: Option<String>,
336 pub previous_sha256: Option<String>,
337 pub replacements: Option<usize>,
338 pub verification: Option<Verification>,
339}
340
341impl FileReceipt {
342 pub fn evidence(&self) -> Value {
344 let mut out = json!({ "exitCode": self.exit_code });
345 if let Some(error) = &self.error {
346 out["error"] = json!(error);
347 }
348 if let Some(bytes) = self.bytes {
349 out["bytes"] = json!(bytes);
350 }
351 if let Some(sha) = &self.sha256 {
352 out["sha256"] = json!(sha);
353 }
354 if let Some(sha) = &self.previous_sha256 {
355 out["previousSha256"] = json!(sha);
356 }
357 if let Some(count) = self.replacements {
358 out["replacements"] = json!(count);
359 }
360 out
361 }
362}
363
364fn fail(target: &str, message: &str) -> FileReceipt {
365 let error = format!("{}: {}", target, message);
366 FileReceipt {
367 exit_code: 1,
368 result: json!({ "ok": false, "is_error": true, "error": error }).to_string(),
369 error: Some(error),
370 ..FileReceipt::default()
371 }
372}
373
374pub fn run_file_call(
377 tool: &str,
378 args: &FileArgs,
379 decision: &FileDecision,
380 context: &mut PolicyContext,
381 max_file_bytes: u64,
382) -> FileReceipt {
383 let target = decision.target.clone().unwrap_or_default();
384 let file = root_of(&context.cwd).join(&target);
385 if tool == "read" {
386 return match read_text(&file, max_file_bytes) {
387 Ok(read) => {
388 let sha = sha256_hex(&read.bytes);
389 context.observed.insert(target.clone());
390 context.contents.insert(target, sha.clone());
391 FileReceipt {
392 exit_code: 0,
393 result: read.text.clone(),
394 text: Some(read.text),
395 bytes: Some(read.bytes.len() as u64),
396 sha256: Some(sha),
397 ..FileReceipt::default()
398 }
399 }
400 Err(message) => fail(&target, &message),
401 };
402 }
403 let mut current = None;
404 if tool == "edit" || file.exists() {
405 match read_text(&file, max_file_bytes) {
406 Ok(read) => {
407 if context
408 .contents
409 .get(&target)
410 .is_some_and(|seen| *seen != sha256_hex(&read.bytes))
411 {
412 return fail(
413 &target,
414 "changed since start read it; read it again before changing it",
415 );
416 }
417 current = Some(read);
418 }
419 Err(message) => return fail(&target, &message),
420 }
421 } else if !file.parent().is_some_and(Path::is_dir) {
422 return fail(&target, "its directory does not exist");
423 }
424 let mut replacements = None;
425 let updated = if tool == "edit" {
426 let text = ¤t.as_ref().expect("edit read the file").text;
427 let old = args.old_string.as_deref().unwrap_or("");
428 let new = args.new_string.as_deref().unwrap_or("");
429 if old.is_empty() {
430 return fail(&target, "old_string is empty");
431 }
432 if old == new {
433 return fail(&target, "old_string and new_string are the same");
434 }
435 let count = text.matches(old).count();
436 if count == 0 {
437 return fail(&target, "old_string was not found; nothing changed");
438 }
439 if count > 1 && !args.replace_all {
440 return fail(
441 &target,
442 &format!(
443 "old_string occurs {} times; give more surrounding text or set replace_all; nothing changed",
444 count
445 ),
446 );
447 }
448 replacements = Some(if args.replace_all { count } else { 1 });
449 if args.replace_all {
450 text.replace(old, new)
451 } else {
452 text.replacen(old, new, 1)
453 }
454 } else {
455 args.content.clone().unwrap_or_default()
456 };
457 let expected = updated.into_bytes();
458 if expected.len() as u64 > max_file_bytes {
459 return fail(
460 &target,
461 &format!(
462 "the new text has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}",
463 expected.len(),
464 max_file_bytes
465 ),
466 );
467 }
468 let permissions = current.as_ref().map(|read| read.permissions.clone());
469 if let Err(error) = write_atomically(&file, &expected, permissions) {
470 return fail(&target, &format!("failed ({})", error));
471 }
472 let readback = fs::read(&file).unwrap_or_default();
473 let verified = readback == expected;
474 let sha = sha256_hex(&readback);
475 context.observed.insert(target.clone());
476 context.contents.insert(target.clone(), sha.clone());
477 FileReceipt {
478 exit_code: 0,
479 result: String::new(),
480 bytes: Some(expected.len() as u64),
481 sha256: Some(sha),
482 previous_sha256: current.as_ref().map(|read| sha256_hex(&read.bytes)),
483 replacements,
484 verification: Some(Verification {
485 verified,
486 detail: if verified {
487 format!(
488 "readback confirmed {} {} ({} bytes)",
489 tool,
490 target,
491 expected.len()
492 )
493 } else {
494 format!("readback of {} differs from the written text", target)
495 },
496 }),
497 ..FileReceipt::default()
498 }
499}
500
501#[cfg(test)]
502#[path = "formal_ai_files_cases.rs"]
503mod formal_ai_files_cases;