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; it observes the file for later changes.
12//! - edit: replaces one literal occurrence of old_string (or every one with
13//!   replace_all) in a file read before, atomically, then reads it back.
14//! - write: replaces an observed file, or creates one in an observed
15//!   directory, atomically, then reads it back.
16//!
17//! Paths follow the bash tool's workspace policy (no absolute, escaping or
18//! symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
19//! workspace-write and an earlier observation of the target, and a file that
20//! changed since start read it is not overwritten.
21
22use 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
37/// The JSON schema of one file tool, as offered to Formal AI.
38pub 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/// Normalized arguments of a file tool call.
72#[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
81/// Argument names Formal AI and other agents use for the same field.
82const 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
104/// Normalize the JSON arguments of a file tool call, or say what is wrong.
105pub 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
165/// One-line rendering of a file call for notes and evidence.
166pub fn describe_file_call(tool: &str, args: &FileArgs) -> String {
167    format!("{} {}", tool, args.path)
168}
169
170/// Whether a file tool call may run (the effect policy of #255).
171#[derive(Debug, Clone, PartialEq, Default)]
172pub struct FileDecision {
173    pub allowed: bool,
174    pub reason: Option<String>,
175    /// "read" or "write".
176    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
192/// Decide whether a file tool call may run.
193pub 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
239/// Hex SHA-256 of some bytes.
240pub 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
253/// Text of a regular workspace file, refusing what read could not return exactly.
254fn 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
291/// Replace `file` by `content` through a temporary sibling and a rename.
292fn 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/// What an approved file call did.
325#[derive(Debug, Clone, PartialEq, Default)]
326pub struct FileReceipt {
327    pub exit_code: i32,
328    /// Tool message for Formal AI: the exact text of a read, empty after a
329    /// change, a JSON error otherwise.
330    pub result: String,
331    /// Text a read returned.
332    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    /// Fields of the receipt the evidence keeps (as JavaScript names them).
343    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
374/// Run an approved file tool call. `context.contents` maps paths to the
375/// SHA-256 start last saw, so a file changed since then is not overwritten.
376pub 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 = &current.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;