Skip to main content

start_command/
formal_ai_files.rs

1//! Structured `read`, `edit` and `write` tools of the experimental
2//! `--use-formal-ai` mode (issue #258). Mirrors js/src/lib/formal-ai-files.js.
3//!
4//! The `bash` tool runs only programs without shell syntax, and its write
5//! programs (cp, mkdir, mv, rm, rmdir, touch) cannot change the text of a
6//! file. These three tools are the guarded replacement primitive instead of an
7//! interpreter: start itself reads and writes the one named workspace file,
8//! byte for byte, so comments, whitespace and unrelated content survive.
9//!
10//! - read: the exact UTF-8 text of a regular file, bounded by
11//!   START_FORMAL_AI_MAX_FILE_BYTES, as the numbered frame Formal AI decodes
12//!   back to that text (#261); it observes the file for later changes.
13//! - edit: replaces one literal occurrence of old_string (or every one with
14//!   replace_all) in a file read before, atomically, then reads it back.
15//! - write: replaces an observed file, or creates one in an observed
16//!   directory, atomically, then reads it back.
17//!
18//! Paths follow the bash tool's workspace policy (no absolute, escaping or
19//! symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
20//! workspace-write and an earlier observation of the target, and a file that
21//! changed since start read it is not overwritten.
22//!
23//! Bounded typed source edits (issue #261): a change to an existing file is
24//! bound to the complete bytes the read tool returned for it in this task
25//! (listing it or `cat` is not enough), applied to exactly the matched regions
26//! of those bytes, guarded against a change of the target until the new file
27//! takes its place, and verified by reading it back: every matched region now
28//! holds new_string and every other byte is unchanged and in place. The
29//! mutation receipt (preimage, postimage and byte offsets of the matches) is
30//! kept in the evidence and returned to Formal AI.
31
32use std::fs::{self, OpenOptions, Permissions};
33use std::io::{Read, Write};
34use std::path::{Path, PathBuf};
35
36use serde_json::{json, Value};
37use sha2::{Digest, Sha256};
38
39use crate::formal_ai_policy::{parent_of, workspace_path, PolicyContext, Verification};
40
41pub const FILE_TOOL_NAMES: [&str; 3] = ["read", "edit", "write"];
42
43fn path_schema() -> Value {
44    json!({ "type": "string", "description": "File path inside the working directory" })
45}
46
47/// The JSON schema of one file tool, as offered to Formal AI.
48pub fn file_tool(name: &str) -> Value {
49    let (description, properties, required) = match name {
50        "read" => (
51            "Read the exact text of one file in the working directory.",
52            json!({ "path": path_schema() }),
53            json!(["path"]),
54        ),
55        "edit" => (
56            "Replace the literal text old_string with new_string in one file read completely with read in this task, unchanged since; old_string must occur exactly once unless replace_all is true. Nothing else in the file changes.",
57            json!({
58                "path": path_schema(),
59                "old_string": { "type": "string" },
60                "new_string": { "type": "string" },
61                "replace_all": { "type": "boolean" },
62            }),
63            json!(["path", "old_string", "new_string"]),
64        ),
65        _ => (
66            "Write the whole text of one file: a file read completely with read in this task, unchanged since, or a new file in a directory listed before.",
67            json!({ "path": path_schema(), "content": { "type": "string" } }),
68            json!(["path", "content"]),
69        ),
70    };
71    json!({
72        "type": "function",
73        "function": {
74            "name": name,
75            "description": description,
76            "parameters": { "type": "object", "properties": properties, "required": required },
77        },
78    })
79}
80
81/// Normalized arguments of a file tool call.
82#[derive(Debug, Clone, PartialEq, Default)]
83pub struct FileArgs {
84    pub path: String,
85    pub old_string: Option<String>,
86    pub new_string: Option<String>,
87    pub replace_all: bool,
88    pub content: Option<String>,
89}
90
91/// Argument names Formal AI and other agents use for the same field.
92const PATH: &[&str] = &["path", "filePath", "file_path"];
93const OLD: &[&str] = &["old_string", "oldString", "old_str", "old"];
94const NEW: &[&str] = &["new_string", "newString", "new_str", "new"];
95const REPLACE_ALL: &[&str] = &["replace_all", "replaceAll"];
96const CONTENT: &[&str] = &["content"];
97
98fn fields(tool: &str) -> &'static [&'static [&'static str]] {
99    match tool {
100        "read" => &[PATH],
101        "edit" => &[PATH, OLD, NEW, REPLACE_ALL],
102        _ => &[PATH, CONTENT],
103    }
104}
105
106fn required(tool: &str) -> &'static [&'static [&'static str]] {
107    match tool {
108        "read" => &[PATH],
109        "edit" => &[PATH, OLD, NEW],
110        _ => &[PATH, CONTENT],
111    }
112}
113
114/// Normalize the JSON arguments of a file tool call, or say what is wrong.
115pub fn normalize_file_args(tool: &str, args: &Value) -> Result<FileArgs, String> {
116    let Some(object) = args.as_object() else {
117        return Err(format!("{} arguments are not a JSON object", tool));
118    };
119    let mut found: Vec<(&str, Value)> = Vec::new();
120    for aliases in fields(tool) {
121        let boolean = *aliases == REPLACE_ALL;
122        let kind = if boolean { "boolean" } else { "string" };
123        let values: Vec<&Value> = aliases.iter().filter_map(|key| object.get(*key)).collect();
124        if values
125            .iter()
126            .any(|value| (boolean && !value.is_boolean()) || (!boolean && !value.is_string()))
127        {
128            return Err(format!(
129                "{} argument {} must be a {}",
130                tool, aliases[0], kind
131            ));
132        }
133        if values.iter().any(|value| *value != values[0]) {
134            return Err(format!(
135                "{} received conflicting {} values",
136                tool, aliases[0]
137            ));
138        }
139        if let Some(value) = values.first() {
140            found.push((aliases[0], (*value).clone()));
141        }
142    }
143    let missing: Vec<&str> = required(tool)
144        .iter()
145        .map(|aliases| aliases[0])
146        .filter(|name| !found.iter().any(|(key, _)| key == name))
147        .collect();
148    if !missing.is_empty() {
149        return Err(format!("{} needs {}", tool, missing.join(", ")));
150    }
151    let text = |name: &str| {
152        found
153            .iter()
154            .find(|(key, _)| *key == name)
155            .and_then(|(_, value)| value.as_str())
156            .map(str::to_string)
157    };
158    let result = FileArgs {
159        path: text("path").unwrap_or_default(),
160        old_string: text("old_string"),
161        new_string: text("new_string"),
162        replace_all: found
163            .iter()
164            .find(|(key, _)| *key == "replace_all")
165            .and_then(|(_, value)| value.as_bool())
166            .unwrap_or(false),
167        content: text("content"),
168    };
169    if result.path.trim().is_empty() || result.path.contains('\0') {
170        return Err(format!("{} needs a non-empty path", tool));
171    }
172    Ok(result)
173}
174
175/// One-line rendering of a file call for notes and evidence.
176pub fn describe_file_call(tool: &str, args: &FileArgs) -> String {
177    format!("{} {}", tool, args.path)
178}
179
180/// Whether a file tool call may run (the effect policy of #255).
181#[derive(Debug, Clone, PartialEq, Default)]
182pub struct FileDecision {
183    pub allowed: bool,
184    pub reason: Option<String>,
185    /// "read" or "write".
186    pub kind: Option<&'static str>,
187    pub target: Option<String>,
188}
189
190fn refuse(reason: String) -> FileDecision {
191    FileDecision {
192        allowed: false,
193        reason: Some(reason),
194        ..FileDecision::default()
195    }
196}
197
198fn root_of(cwd: &Path) -> PathBuf {
199    fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
200}
201
202/// Decide whether a file tool call may run.
203pub fn evaluate_file_call(tool: &str, args: &FileArgs, context: &PolicyContext) -> FileDecision {
204    let kind = if tool == "read" { "read" } else { "write" };
205    if kind == "write" && context.effects != "workspace-write" {
206        return refuse(format!(
207            "\"{}\" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)",
208            tool
209        ));
210    }
211    let outside = || {
212        refuse(format!(
213            "\"{}\" is outside the working directory",
214            args.path
215        ))
216    };
217    if args.path.starts_with('~') {
218        return outside();
219    }
220    let Some(target) = workspace_path(&context.cwd, &args.path) else {
221        return outside();
222    };
223    if target == "." {
224        return refuse(format!(
225            "\"{}\" is the working directory, not a file",
226            args.path
227        ));
228    }
229    if kind == "write" {
230        let exists = root_of(&context.cwd).join(&target).exists();
231        let observed = context.observed.contains(&target)
232            || (tool == "write" && !exists && context.observed_dirs.contains(&parent_of(&target)));
233        if !observed {
234            return refuse(format!(
235                "target \"{}\" was not {} before the change",
236                target,
237                if tool == "edit" { "read" } else { "observed" }
238            ));
239        }
240    }
241    FileDecision {
242        allowed: true,
243        reason: None,
244        kind: Some(kind),
245        target: Some(target),
246    }
247}
248
249/// Hex SHA-256 of some bytes.
250pub fn sha256_hex(data: &[u8]) -> String {
251    Sha256::digest(data)
252        .iter()
253        .map(|byte| format!("{:02x}", byte))
254        .collect()
255}
256
257struct FileText {
258    text: String,
259    bytes: Vec<u8>,
260    permissions: Permissions,
261}
262
263const LINK_APPEARED: &str =
264    "is a symbolic link that appeared after start resolved the path; nothing changed";
265
266/// `symlink_metadata` of `file`, or None when it does not exist.
267fn lstat_or_none(file: &Path) -> Result<Option<fs::Metadata>, String> {
268    match fs::symlink_metadata(file) {
269        Ok(meta) => Ok(Some(meta)),
270        Err(error) if missing(&error) => Ok(None),
271        Err(error) => Err(format!("cannot inspect the file ({})", error)),
272    }
273}
274
275fn missing(error: &std::io::Error) -> bool {
276    #[cfg(unix)]
277    if error.raw_os_error() == Some(libc::ENOTDIR) {
278        return true;
279    }
280    error.kind() == std::io::ErrorKind::NotFound
281}
282
283/// Open the final path component without following a symbolic link: the
284/// policy resolved the target, so a link there now is a swapped path (#261).
285fn open_no_follow(file: &Path) -> std::io::Result<fs::File> {
286    let mut options = OpenOptions::new();
287    options.read(true);
288    #[cfg(unix)]
289    {
290        use std::os::unix::fs::OpenOptionsExt;
291        options.custom_flags(libc::O_NOFOLLOW);
292    }
293    options.open(file)
294}
295
296/// The exact bytes of a regular workspace file, read through one handle
297/// opened without following a symbolic link.
298fn read_bytes(file: &Path, max_file_bytes: u64) -> Result<(Vec<u8>, Permissions), String> {
299    match lstat_or_none(file)? {
300        Some(meta) if meta.file_type().is_symlink() => return Err(LINK_APPEARED.to_string()),
301        // Refused before opening: Windows denies opening a directory as a
302        // file (os error 5); the handle check below still covers a swap.
303        Some(meta) if meta.is_dir() => return Err("is a directory, not a file".to_string()),
304        _ => {}
305    }
306    let mut handle = open_no_follow(file).map_err(|error| {
307        #[cfg(unix)]
308        if error.raw_os_error() == Some(libc::ELOOP) {
309            return LINK_APPEARED.to_string();
310        }
311        if error.kind() == std::io::ErrorKind::NotFound {
312            "no such file".to_string()
313        } else {
314            format!("cannot read the file ({})", error)
315        }
316    })?;
317    let meta = handle
318        .metadata()
319        .map_err(|error| format!("cannot read the file ({})", error))?;
320    if meta.is_dir() {
321        return Err("is a directory, not a file".to_string());
322    }
323    if !meta.is_file() {
324        return Err("is not a regular file".to_string());
325    }
326    let too_large = |size: u64| {
327        format!(
328            "has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}",
329            size, max_file_bytes
330        )
331    };
332    if meta.len() > max_file_bytes {
333        return Err(too_large(meta.len()));
334    }
335    let mut bytes = Vec::new();
336    // One byte past the bound is enough to see the file grew beyond it.
337    (&mut handle)
338        .take(max_file_bytes + 1)
339        .read_to_end(&mut bytes)
340        .map_err(|error| format!("cannot read the file ({})", error))?;
341    if bytes.len() as u64 > max_file_bytes {
342        return Err(too_large(bytes.len() as u64));
343    }
344    Ok((bytes, meta.permissions()))
345}
346
347/// Text of a regular workspace file, refusing what read could not return exactly.
348fn read_text(file: &Path, max_file_bytes: u64) -> Result<FileText, String> {
349    let (bytes, permissions) = read_bytes(file, max_file_bytes)?;
350    match std::str::from_utf8(&bytes) {
351        Ok(text) if !text.contains('\0') => Ok(FileText {
352            text: text.to_string(),
353            bytes: bytes.clone(),
354            permissions,
355        }),
356        _ => Err("is not UTF-8 text".to_string()),
357    }
358}
359
360/// Why a change did not complete: a message of start's own, or an I/O error.
361enum WriteError {
362    Tool(String),
363    Io(std::io::Error),
364}
365
366impl WriteError {
367    fn message(&self) -> String {
368        match self {
369            WriteError::Tool(message) => message.clone(),
370            WriteError::Io(error) => format!("failed ({})", error),
371        }
372    }
373}
374
375impl From<std::io::Error> for WriteError {
376    fn from(error: std::io::Error) -> Self {
377        WriteError::Io(error)
378    }
379}
380
381/// Replace `file` by `content` through a temporary sibling. Just before the
382/// temporary file takes the target's place, `guard()` checks that the target
383/// still holds the preimage start bound the change to; a new file is linked in
384/// place, so a file that appeared meanwhile is never overwritten.
385fn write_atomically(
386    file: &Path,
387    content: &[u8],
388    permissions: Option<Permissions>,
389    guard: &dyn Fn() -> Result<(), String>,
390    create: bool,
391) -> Result<(), WriteError> {
392    let name = file
393        .file_name()
394        .map(|n| n.to_string_lossy().to_string())
395        .unwrap_or_default();
396    let temporary = file.with_file_name(format!(
397        ".{}.{}.{}.start-tmp",
398        name,
399        std::process::id(),
400        uuid::Uuid::new_v4()
401    ));
402    let result = (|| {
403        let mut handle = OpenOptions::new()
404            .write(true)
405            .create_new(true)
406            .open(&temporary)?;
407        handle.write_all(content)?;
408        if let Some(permissions) = permissions {
409            fs::set_permissions(&temporary, permissions)?;
410        }
411        handle.sync_all()?;
412        drop(handle);
413        guard().map_err(WriteError::Tool)?;
414        if create {
415            fs::hard_link(&temporary, file).map_err(|error| {
416                if error.kind() == std::io::ErrorKind::AlreadyExists {
417                    WriteError::Tool(
418                        "was created while start was writing it; nothing changed".to_string(),
419                    )
420                } else {
421                    WriteError::Io(error)
422                }
423            })
424        } else {
425            fs::rename(&temporary, file).map_err(WriteError::Io)
426        }
427    })();
428    let _ = fs::remove_file(&temporary);
429    result
430}
431
432/// One matched region of a mutation receipt, in bytes of the preimage.
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub struct Match {
435    pub offset: usize,
436    pub old_bytes: usize,
437    pub new_bytes: usize,
438}
439
440impl Match {
441    fn to_json(self) -> Value {
442        json!({ "offset": self.offset, "oldBytes": self.old_bytes, "newBytes": self.new_bytes })
443    }
444}
445
446/// Byte-level splice of `preimage`: each match's old bytes become `new_bytes`.
447fn splice(preimage: &[u8], matches: &[Match], new_bytes: &[u8]) -> Vec<u8> {
448    let mut out = Vec::with_capacity(preimage.len());
449    let mut cursor = 0;
450    for found in matches {
451        out.extend_from_slice(&preimage[cursor..found.offset]);
452        out.extend_from_slice(new_bytes);
453        cursor = found.offset + found.old_bytes;
454    }
455    out.extend_from_slice(&preimage[cursor..]);
456    out
457}
458
459/// Check a readback against the mutation receipt, independently of how the
460/// new text was computed: every matched preimage region held exactly the old
461/// bytes, every other preimage byte is unchanged and in place, every replaced
462/// region holds exactly the new bytes, and nothing else was added.
463pub fn verify_mutation(
464    preimage: &[u8],
465    readback: &[u8],
466    matches: &[Match],
467    old_bytes: &[u8],
468    new_bytes: &[u8],
469) -> Verification {
470    let bad = |detail: String| Verification {
471        verified: false,
472        detail,
473    };
474    if matches.is_empty() {
475        return bad("the mutation receipt has no matched region".to_string());
476    }
477    let expected_len = (preimage.len() as i128)
478        + (matches.len() as i128) * (new_bytes.len() as i128 - old_bytes.len() as i128);
479    if readback.len() as i128 != expected_len {
480        return bad(format!(
481            "readback has {} bytes, expected {}",
482            readback.len(),
483            expected_len
484        ));
485    }
486    let mut before = 0;
487    let mut after = 0;
488    for (index, found) in matches.iter().enumerate() {
489        let number = index + 1;
490        if found.offset < before
491            || found.old_bytes != old_bytes.len()
492            || found.new_bytes != new_bytes.len()
493            || found.offset + found.old_bytes > preimage.len()
494        {
495            return bad(format!(
496                "match {} of the mutation receipt is malformed",
497                number
498            ));
499        }
500        let keep = found.offset - before;
501        if readback.get(after..after + keep) != Some(&preimage[before..found.offset]) {
502            return bad(format!("bytes before match {} changed", number));
503        }
504        after += keep;
505        if &preimage[found.offset..found.offset + found.old_bytes] != old_bytes {
506            return bad(format!(
507                "match {} did not hold old_string in the preimage",
508                number
509            ));
510        }
511        if readback.get(after..after + new_bytes.len()) != Some(new_bytes) {
512            return bad(format!(
513                "match {} does not hold new_string after the change",
514                number
515            ));
516        }
517        before = found.offset + found.old_bytes;
518        after += new_bytes.len();
519    }
520    if readback.get(after..) != Some(&preimage[before..]) {
521        return bad("bytes after the last match changed".to_string());
522    }
523    Verification {
524        verified: true,
525        detail: format!(
526            "{} replacement(s); every other byte unchanged",
527            matches.len()
528        ),
529    }
530}
531
532fn digest(bytes: &[u8]) -> Value {
533    json!({ "sha256": sha256_hex(bytes), "bytes": bytes.len() })
534}
535
536/// The complete text of a read as the numbered frame Formal AI binds as a
537/// whole-file Read (#261): one `NNNNN| line` per `\n`-separated line and a
538/// trailer with the line count. Formal AI decodes it back to exactly `text`
539/// (lines joined with `\n`, so a missing or present final newline, CR bytes
540/// and Unicode survive); its OpenAI-compatible request parser drops any other
541/// metadata on a tool message, so the frame is the only binding it accepts.
542pub fn read_frame(text: &str) -> String {
543    let lines: Vec<&str> = text.split('\n').collect();
544    let width = lines.len().to_string().len().max(5);
545    let numbered: Vec<String> = lines
546        .iter()
547        .enumerate()
548        .map(|(index, line)| format!("{:0width$}| {}", index + 1, line, width = width))
549        .collect();
550    format!(
551        "<file>\n{}\n\n(End of file - total {} lines)\n</file>",
552        numbered.join("\n"),
553        lines.len()
554    )
555}
556
557/// The exact text a read_frame() result frames, or None when it is not one.
558pub fn read_frame_text(frame: &str) -> Option<String> {
559    const OPEN: &str = "<file>\n";
560    const MARKER: &str = "\n\n(End of file - total ";
561    if !frame.starts_with(OPEN) || !frame.ends_with("</file>") {
562        return None;
563    }
564    let at = frame.rfind(MARKER)?;
565    if at < OPEN.len() {
566        return None;
567    }
568    let lines: Vec<&str> = frame[OPEN.len()..at].split('\n').collect();
569    let count: usize = frame[at + MARKER.len()..]
570        .split_whitespace()
571        .next()?
572        .parse()
573        .ok()?;
574    if count != lines.len() {
575        return None;
576    }
577    let mut out = Vec::with_capacity(lines.len());
578    for (index, line) in lines.iter().enumerate() {
579        let split = line.find("| ")?;
580        if line[..split].parse::<usize>().ok()? != index + 1 {
581            return None;
582        }
583        out.push(&line[split + 2..]);
584    }
585    Some(out.join("\n"))
586}
587
588/// Whether a tool result of the conversation already contained `text`, as
589/// sent or as the file text of a read frame (whose lines carry numbers): an
590/// answer that only echoes file data back is not Formal AI's own (#261).
591pub fn echoed_by_tool(text: &str, messages: &[Value]) -> bool {
592    messages.iter().any(|m| {
593        if m["role"] != "tool" {
594            return false;
595        }
596        let content = match &m["content"] {
597            Value::String(content) => content.clone(),
598            Value::Null => String::new(),
599            other => other.to_string(),
600        };
601        content.contains(text) || read_frame_text(&content).is_some_and(|t| t.contains(text))
602    })
603}
604
605/// What an approved file call did.
606#[derive(Debug, Clone, PartialEq, Default)]
607pub struct FileReceipt {
608    pub exit_code: i32,
609    /// Tool message for Formal AI: the numbered frame of a read, the JSON
610    /// receipt of a change, a JSON error otherwise.
611    pub result: String,
612    /// Text a read returned.
613    pub text: Option<String>,
614    pub error: Option<String>,
615    pub bytes: Option<u64>,
616    pub sha256: Option<String>,
617    pub previous_sha256: Option<String>,
618    pub replacements: Option<usize>,
619    /// Mutation receipt: tool, target, preimage, postimage, matches?, readback?
620    pub mutation: Option<Value>,
621    pub verification: Option<Verification>,
622}
623
624impl FileReceipt {
625    /// Fields of the receipt the evidence keeps (as JavaScript names them).
626    pub fn evidence(&self) -> Value {
627        let mut out = json!({ "exitCode": self.exit_code });
628        if let Some(error) = &self.error {
629            out["error"] = json!(error);
630        }
631        if let Some(bytes) = self.bytes {
632            out["bytes"] = json!(bytes);
633        }
634        if let Some(sha) = &self.sha256 {
635            out["sha256"] = json!(sha);
636        }
637        if let Some(sha) = &self.previous_sha256 {
638            out["previousSha256"] = json!(sha);
639        }
640        if let Some(count) = self.replacements {
641            out["replacements"] = json!(count);
642        }
643        if let Some(mutation) = &self.mutation {
644            out["mutation"] = mutation.clone();
645        }
646        out
647    }
648}
649
650fn fail(target: &str, message: &str) -> FileReceipt {
651    let error = format!("{}: {}", target, message);
652    FileReceipt {
653        exit_code: 1,
654        result: json!({ "ok": false, "is_error": true, "error": error }).to_string(),
655        error: Some(error),
656        ..FileReceipt::default()
657    }
658}
659
660/// The tool result Formal AI receives for a change: its receipt, as JSON.
661fn change_result(receipt: &Value, verified: bool) -> String {
662    let mut out = json!({
663        "ok": verified,
664        "path": receipt["target"],
665        "tool": receipt["tool"],
666        "preimage": receipt["preimage"],
667        "postimage": receipt["postimage"],
668        "verified": verified,
669    });
670    if let Some(matches) = receipt["matches"].as_array() {
671        out["replacements"] = json!(matches.len());
672    }
673    out.to_string()
674}
675
676/// Test hooks: run just before the guard and just before the readback.
677#[derive(Default)]
678pub struct FileHooks<'a> {
679    pub before_replace: Option<&'a dyn Fn(&Path)>,
680    pub after_replace: Option<&'a dyn Fn(&Path)>,
681}
682
683/// Run an approved file tool call.
684///
685/// A change is bound to the complete bytes of the target that this task last
686/// read with the read tool (or wrote and verified): `context.contents` maps
687/// paths to their SHA-256. The edit is applied to those bytes only, the target
688/// is checked to still hold them just before the new file takes its place,
689/// and the readback is verified byte for byte against the mutation receipt
690/// (edit) or the whole intended text (write).
691pub fn run_file_call(
692    tool: &str,
693    args: &FileArgs,
694    decision: &FileDecision,
695    context: &mut PolicyContext,
696    max_file_bytes: u64,
697) -> FileReceipt {
698    run_file_call_with(
699        tool,
700        args,
701        decision,
702        context,
703        max_file_bytes,
704        &FileHooks::default(),
705    )
706}
707
708/// run_file_call() with test hooks.
709pub fn run_file_call_with(
710    tool: &str,
711    args: &FileArgs,
712    decision: &FileDecision,
713    context: &mut PolicyContext,
714    max_file_bytes: u64,
715    hooks: &FileHooks<'_>,
716) -> FileReceipt {
717    let target = decision.target.clone().unwrap_or_default();
718    let file = root_of(&context.cwd).join(&target);
719    if tool == "read" {
720        return match read_text(&file, max_file_bytes) {
721            Ok(read) => {
722                let sha = sha256_hex(&read.bytes);
723                context.observed.insert(target.clone());
724                context.contents.insert(target, sha.clone());
725                FileReceipt {
726                    exit_code: 0,
727                    result: read_frame(&read.text),
728                    text: Some(read.text),
729                    bytes: Some(read.bytes.len() as u64),
730                    sha256: Some(sha),
731                    ..FileReceipt::default()
732                }
733            }
734            Err(message) => fail(&target, &message),
735        };
736    }
737    let existing = match lstat_or_none(&file) {
738        Ok(existing) => existing,
739        Err(message) => return fail(&target, &message),
740    };
741    if existing
742        .as_ref()
743        .is_some_and(|meta| meta.file_type().is_symlink())
744    {
745        return fail(
746            &target,
747            "is a symbolic link start cannot resolve inside the working directory; nothing changed",
748        );
749    }
750    let mut current = None;
751    if tool == "edit" || existing.is_some() {
752        let Some(seen) = context.contents.get(&target).cloned() else {
753            return fail(
754                &target,
755                "was not read completely with the read tool in this task; read it before changing it; nothing changed",
756            );
757        };
758        match read_text(&file, max_file_bytes) {
759            Ok(read) if seen == sha256_hex(&read.bytes) => current = Some(read),
760            Ok(_) => return fail(
761                &target,
762                "changed since start read it; read it again before changing it; nothing changed",
763            ),
764            Err(message) => return fail(&target, &message),
765        }
766    } else if !file.parent().is_some_and(Path::is_dir) {
767        return fail(&target, "its directory does not exist");
768    }
769    let mut matches: Option<Vec<Match>> = None;
770    let old = args.old_string.as_deref().unwrap_or("");
771    let new = args.new_string.as_deref().unwrap_or("");
772    let expected = if tool == "edit" {
773        let read = current.as_ref().expect("edit read the file");
774        if old.is_empty() {
775            return fail(&target, "old_string is empty; nothing changed");
776        }
777        if old == new {
778            return fail(
779                &target,
780                "old_string and new_string are the same; nothing changed",
781            );
782        }
783        // Rust strings are always valid Unicode: a lone surrogate never
784        // reaches here (its JSON arguments do not parse).
785        let found: Vec<usize> = read.text.match_indices(old).map(|(at, _)| at).collect();
786        if found.is_empty() {
787            return fail(&target, "old_string was not found; nothing changed");
788        }
789        if found.len() > 1 && !args.replace_all {
790            return fail(
791                &target,
792                &format!(
793                    "old_string occurs {} times; give more surrounding text or set replace_all; nothing changed",
794                    found.len()
795                ),
796            );
797        }
798        let chosen = if args.replace_all {
799            &found[..]
800        } else {
801            &found[..1]
802        };
803        let list: Vec<Match> = chosen
804            .iter()
805            .map(|offset| Match {
806                offset: *offset,
807                old_bytes: old.len(),
808                new_bytes: new.len(),
809            })
810            .collect();
811        let spliced = splice(&read.bytes, &list, new.as_bytes());
812        // The byte splice and the text replacement must agree exactly.
813        let text = if args.replace_all {
814            read.text.replace(old, new)
815        } else {
816            read.text.replacen(old, new, 1)
817        };
818        if spliced != text.as_bytes() {
819            return fail(
820                &target,
821                "the replacement does not map to whole UTF-8 characters; nothing changed",
822            );
823        }
824        matches = Some(list);
825        spliced
826    } else {
827        args.content.clone().unwrap_or_default().into_bytes()
828    };
829    if expected.len() as u64 > max_file_bytes {
830        return fail(
831            &target,
832            &format!(
833                "the new text has {} bytes; START_FORMAL_AI_MAX_FILE_BYTES is {}; nothing changed",
834                expected.len(),
835                max_file_bytes
836            ),
837        );
838    }
839    let mut receipt = json!({
840        "tool": tool,
841        "target": target,
842        "preimage": current.as_ref().map(|read| digest(&read.bytes)),
843        "postimage": digest(&expected),
844    });
845    if let Some(list) = &matches {
846        receipt["matches"] = json!(list.iter().map(|m| m.to_json()).collect::<Vec<_>>());
847    }
848    let preimage = current.as_ref().map(|read| read.bytes.as_slice());
849    let guard = || {
850        if let Some(hook) = hooks.before_replace {
851            hook(&file);
852        }
853        if still_holds(&file, preimage, max_file_bytes) {
854            Ok(())
855        } else if preimage.is_some() {
856            Err("changed while start was changing it; read it again; nothing changed".to_string())
857        } else {
858            Err("was created while start was writing it; nothing changed".to_string())
859        }
860    };
861    let permissions = current.as_ref().map(|read| read.permissions.clone());
862    if let Err(error) = write_atomically(&file, &expected, permissions, &guard, current.is_none()) {
863        let message = error.message();
864        // The change did not complete: say truthfully whether the target
865        // still holds its preimage, and forget a preimage that no longer holds.
866        let intact = still_holds(&file, preimage, max_file_bytes);
867        if !intact {
868            context.contents.remove(&target);
869        }
870        let message = if intact || message.ends_with("nothing changed") {
871            format!(
872                "{}; nothing changed",
873                message.trim_end_matches("; nothing changed")
874            )
875        } else {
876            format!("{}; the file may have changed; read it again", message)
877        };
878        return FileReceipt {
879            mutation: Some(receipt),
880            ..fail(&target, &message)
881        };
882    }
883    context.observed.insert(target.clone());
884    let previous_sha256 = preimage.map(sha256_hex);
885    let replacements = matches.as_ref().map(Vec::len);
886    if let Some(hook) = hooks.after_replace {
887        hook(&file);
888    }
889    let readback = match read_bytes(&file, max_file_bytes) {
890        Ok((bytes, _)) => bytes,
891        Err(message) => {
892            context.contents.remove(&target);
893            return FileReceipt {
894                exit_code: 0,
895                result: change_result(&receipt, false),
896                bytes: Some(expected.len() as u64),
897                previous_sha256,
898                replacements,
899                mutation: Some(receipt),
900                verification: Some(Verification {
901                    verified: false,
902                    detail: format!("readback of {} failed ({})", target, message),
903                }),
904                ..FileReceipt::default()
905            };
906        }
907    };
908    let check = match (&matches, preimage) {
909        (Some(list), Some(preimage)) => {
910            verify_mutation(preimage, &readback, list, old.as_bytes(), new.as_bytes())
911        }
912        _ => Verification {
913            verified: true,
914            detail: "whole text".to_string(),
915        },
916    };
917    let verified = check.verified && readback == expected;
918    receipt["readback"] = digest(&readback);
919    let sha = sha256_hex(&readback);
920    if verified {
921        context.contents.insert(target.clone(), sha.clone());
922    } else {
923        context.contents.remove(&target);
924    }
925    FileReceipt {
926        exit_code: 0,
927        result: change_result(&receipt, verified),
928        bytes: Some(expected.len() as u64),
929        sha256: Some(sha),
930        previous_sha256,
931        replacements,
932        mutation: Some(receipt),
933        verification: Some(Verification {
934            verified,
935            detail: if verified {
936                format!(
937                    "readback confirmed {} {} ({} bytes; {})",
938                    tool,
939                    target,
940                    expected.len(),
941                    check.detail
942                )
943            } else if check.verified {
944                format!("readback of {} differs from the intended text", target)
945            } else {
946                format!(
947                    "readback of {} differs from the intended text ({})",
948                    target, check.detail
949                )
950            },
951        }),
952        ..FileReceipt::default()
953    }
954}
955
956/// Whether `file` still holds the bound preimage (or is still absent).
957fn still_holds(file: &Path, preimage: Option<&[u8]>, max_file_bytes: u64) -> bool {
958    match (lstat_or_none(file), preimage) {
959        (Ok(None), None) => true,
960        (Ok(Some(meta)), Some(preimage)) if !meta.file_type().is_symlink() => {
961            read_bytes(file, max_file_bytes).is_ok_and(|(bytes, _)| bytes == preimage)
962        }
963        _ => false,
964    }
965}
966
967#[cfg(test)]
968#[path = "formal_ai_files_cases.rs"]
969mod formal_ai_files_cases;