Skip to main content

runtime/tools/
edit.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The edit tool: optimistic concurrency against a version the caller
7//! must already have seen.
8//!
9//! Two refusals carry the design. A `base_version` that no longer
10//! matches means the file moved under the caller — a blind overwrite
11//! would erase whoever moved it, so the edit is refused with the
12//! current version named. And an `old` string that matches zero times
13//! or many times is refused with the count, because "replace the first
14//! one" is a guess about which occurrence was meant.
15//!
16//! The echo is a unified diff. Per-edit diffs are the restoration
17//! granularity: a checkpoint restores a wave, but a diff restores one
18//! change, and that is the difference between undoing a mistake and
19//! undoing an afternoon.
20
21use std::path::{Path, PathBuf};
22
23use kernel::{
24    AxCode, AxError, B3Hash, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall,
25    ToolMeta, ToolName, ToolOutcome,
26};
27use serde_json::{Map, Value};
28
29pub struct EditTool {
30    city_root: PathBuf,
31    /// The run's write domain. Every model-chosen path is judged against
32    /// it before the filesystem is touched.
33    writable: kernel::WriteDomain,
34    /// The run's policy cell, asked at each write for what the run may do
35    /// to a file that already exists (`crates/runtime/spec/Tools.lean`
36    /// §8-55, `crates/runtime/spec/PolicyTake.lean` §8-62).
37    policy: crate::mode::PolicyReader,
38    meta: ToolMeta,
39}
40
41/// The version stamp a caller must present: the content's whole
42/// `cas:b3-<hex64>` Locator. `read` prints the same value and `plan
43/// finish` takes it as evidence, so a model copies one string between
44/// three tools and never shortens or completes it (kernel
45/// `spec/Locator.lean` D27).
46pub fn version_of(bytes: &[u8]) -> String {
47    kernel::Locator::cas(B3Hash::digest(bytes)).to_string()
48}
49
50/// The version word that means "I expect this file to not exist yet".
51/// A Locator can never spell it, so it cannot collide with a real
52/// version.
53const CREATES: &str = "new";
54
55impl EditTool {
56    /// The name this tool answers to, read by `mode::core_tools` too.
57    pub const NAME: &'static str = "edit";
58
59    pub fn new(
60        city_root: &Path,
61        domain: kernel::Address,
62        writable: kernel::WriteDomain,
63        policy: crate::mode::PolicyReader,
64    ) -> Result<EditTool, AxError> {
65        let params = Payload::of(&serde_json::json!({
66            "type": "object",
67            "properties": {
68                "path": { "type": "string",
69                    "description": "file to edit, relative to the city root" },
70                "base_version": { "type": "string", "description":
71                    "the `version` read printed for this file; refuses if it moved. Pass \"new\" to create the file" },
72                "old": { "type": "string", "description":
73                    "exact text to replace; must match exactly once. \"\" when creating" },
74                "new": { "type": "string",
75                    "description": "replacement text, or the whole file when creating" },
76            },
77            "required": ["path", "base_version", "old", "new"],
78        }))?;
79        Ok(EditTool {
80            city_root: city_root.to_path_buf(),
81            writable,
82            policy,
83            meta: ToolMeta {
84                name: ToolName::parse(Self::NAME)?,
85                // The write limit is not spelled here: it can change
86                // while the run goes, and these bytes are frozen with the
87                // tool list (§8-62); the note a change appends spells it.
88                disclosure: "Replace an exact string in a file, guarded by the version you \
89                             last saw: `old` must match exactly once or the call fails. A file \
90                             that moved under you is refused and not overwritten; read it \
91                             again and retry. A `base_version` of `new` creates the file."
92                    .to_owned(),
93                params,
94                effect: Effect::Write { domain },
95                cost_tier: CostTier::Light,
96                timeout: None,
97                render: RenderIntent::Diff {
98                    locations: Vec::new(),
99                },
100                temporal: Temporal::Timeless,
101            },
102        })
103    }
104}
105
106fn arg<'a>(args: &'a Map<String, Value>, key: &str) -> Result<&'a str, AxError> {
107    args.get(key).and_then(Value::as_str).ok_or_else(|| {
108        AxError::failure(
109            AxCode::InvalidArgs,
110            "edit file",
111            format!("missing string argument `{key}`"),
112        )
113        .with_recovery("pass path, base_version, old and new - all strings")
114    })
115}
116
117impl Tool for EditTool {
118    fn meta(&self) -> &ToolMeta {
119        &self.meta
120    }
121
122    /// The one file the call names. A path that does not parse wrote
123    /// nothing, since `invoke` refuses it before touching the disk, but
124    /// the answer stays `Domain` so that no reading of it can come out
125    /// narrower than the truth.
126    fn writes(&self, call: &ToolCall) -> kernel::Writes {
127        match arg(call.args.as_map(), "path")
128            .and_then(|asked| super::chosen_path::within_city(&self.city_root, asked, "edit file"))
129            .and_then(|spelled| kernel::Address::parse(&spelled))
130        {
131            Ok(path) => kernel::Writes::Paths(vec![path]),
132            Err(_) => kernel::Writes::Domain,
133        }
134    }
135
136    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
137        if call.name != self.meta.name {
138            return Err(AxError::failure(
139                AxCode::InvalidArgs,
140                "edit file",
141                format!("call routed to the wrong tool: {}", call.name.as_str()),
142            )
143            .with_recovery(format!(
144                "call `{}`, the name this tool answers to",
145                self.meta.name.as_str()
146            )));
147        }
148        // Read once: every door below judges this call under one policy.
149        let policy = self.policy.now();
150        crate::mode::mode_admits(&policy, Self::NAME)?;
151        let args = call.args.as_map();
152        let spelled =
153            super::chosen_path::within_city(&self.city_root, arg(args, "path")?, "edit file")?;
154        let rel: &str = &spelled;
155        let base_version = arg(args, "base_version")?;
156        let old = arg(args, "old")?;
157        let new = arg(args, "new")?;
158
159        // The path is judged before the filesystem is touched, and by the
160        // same two authorities everything else uses: Address::parse kills
161        // traversal, and the write-domain gate judges the file the way
162        // the bench judged the declared area. The bench only ever sees
163        // the tool's own declaration; a model-chosen path is judged
164        // here, and by the same door.
165        let target = kernel::Address::parse(rel).map_err(|err| {
166            AxError::failure(
167                AxCode::InvalidArgs,
168                "edit file",
169                format!("{rel}: {}", err.subject()),
170            )
171            .with_recovery("pass a city-relative path with no `..` and no leading slash")
172        })?;
173        match kernel::gate::domain(&self.writable, &target, &kernel::TaintSet::empty()) {
174            kernel::GateOutcome::Allow => {}
175            // The write doors do not ask. Were one ever to, the call is
176            // pending, not allowed.
177            kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
178            kernel::GateOutcome::Ask { question } => return Err(*question),
179        }
180
181        let path = self.city_root.join(rel);
182        if base_version == CREATES {
183            return self.create(rel, &path, old, new);
184        }
185        // Every arm below changes a file that is already there.
186        match kernel::gate::replacing(policy.write, &target) {
187            kernel::GateOutcome::Allow => {}
188            kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
189            kernel::GateOutcome::Ask { question } => return Err(*question),
190        }
191        let bytes = std::fs::read(&path).map_err(|err| {
192            AxError::failure(AxCode::InvalidArgs, "edit file", format!("{rel}: {err}"))
193                .with_recovery(
194                    "check the path, relative to the city root; to create a new file pass \
195                     base_version:\"new\"",
196                )
197        })?;
198        let current_version = version_of(&bytes);
199        if current_version != base_version {
200            return Err(AxError::failure(
201                AxCode::VersionConflict,
202                "edit file",
203                format!("{rel} is at {current_version}, not {base_version}"),
204            )
205            .with_recovery("read the file again, then edit against the version you just saw"));
206        }
207        let text = String::from_utf8(bytes).map_err(|_| {
208            AxError::failure(
209                AxCode::InvalidArgs,
210                "edit file",
211                format!("{rel} is not valid UTF-8"),
212            )
213            .with_recovery(format!(
214                "edit {rel} in a program that reads its bytes; this tool changes text \
215                 files and this one is not text"
216            ))
217        })?;
218        let hits = text.matches(old).count();
219        if hits != 1 {
220            return Err(AxError::failure(
221                AxCode::InvalidArgs,
222                "edit file",
223                format!("`old` matches {hits} times in {rel}, not once"),
224            )
225            .with_recovery("include enough surrounding text to name exactly one occurrence"));
226        }
227        let updated = text.replacen(old, new, 1);
228        // A name that is a link lands this write somewhere the gate
229        // never judged - `.git/hooks` through a junction is privilege
230        // escalation (`crates/storage/Spec.lean` §8-25). The replacement lands the way
231        // every city write does: a staging file renamed over the name,
232        // so a failed write leaves the old file and a hard link's other
233        // names keep their bytes (`crates/storage/Spec.lean` §8-32).
234        storage::WriteTarget::within("edit file", &self.city_root, &path)
235            .and_then(|cleared| cleared.replace(updated.as_bytes()))
236            .map_err(storage::StorageError::into_ax)?;
237        let new_version = version_of(updated.as_bytes());
238
239        let mut result = Map::new();
240        result.insert("path".to_owned(), Value::String(rel.to_owned()));
241        result.insert(
242            "base_version".to_owned(),
243            Value::String(base_version.to_owned()),
244        );
245        result.insert("new_version".to_owned(), Value::String(new_version));
246        result.insert(
247            "diff".to_owned(),
248            Value::String(unified_diff(rel, &text, &updated)),
249        );
250        Ok(ToolOutcome {
251            result: Payload::new(result)?,
252            attachments: Vec::new(),
253        })
254    }
255}
256
257impl EditTool {
258    /// The creating arm: the caller declared the file absent, so an
259    /// existing file is a version conflict like any other - it names the
260    /// version the file is really at.
261    fn create(&self, rel: &str, path: &Path, old: &str, new: &str) -> Result<ToolOutcome, AxError> {
262        if !old.is_empty() {
263            return Err(AxError::failure(
264                AxCode::InvalidArgs,
265                "edit file",
266                "creating a file replaces nothing: `old` must be \"\"",
267            )
268            .with_recovery("pass old:\"\" and the whole file in `new`"));
269        }
270        // The alias check runs before any directory is made: making a
271        // parent through a link would land a directory inside the
272        // protected metadata the link reaches, before the write itself
273        // is refused (`crates/storage/Spec.lean` §8-25).
274        storage::WriteTarget::within("edit file", &self.city_root, path)
275            .map_err(|err| err.into_ax())?;
276        if let Some(parent) = path.parent() {
277            std::fs::create_dir_all(parent).map_err(|err| {
278                AxError::failure(AxCode::StorageFatal, "edit file", format!("{rel}: {err}"))
279                    .with_recovery(format!(
280                        "free space on the disk holding the city, or remove the file \
281                         standing where {rel}'s parent directory has to go"
282                    ))
283            })?;
284        }
285        // The filesystem claims the name at the write, so of two racing
286        // creates one lands, under either limit (`crates/storage/Spec.lean` §8-32).
287        storage::WriteTarget::within("edit file", &self.city_root, path)
288            .and_then(|cleared| cleared.create(new.as_bytes()))
289            .map_err(|err| refused_create(err, rel, path))?;
290        let mut result = Map::new();
291        result.insert("path".to_owned(), Value::String(rel.to_owned()));
292        result.insert("base_version".to_owned(), Value::String(CREATES.to_owned()));
293        result.insert(
294            "new_version".to_owned(),
295            Value::String(version_of(new.as_bytes())),
296        );
297        result.insert("diff".to_owned(), Value::String(unified_diff(rel, "", new)));
298        Ok(ToolOutcome {
299            result: Payload::new(result)?,
300            attachments: Vec::new(),
301        })
302    }
303}
304
305/// Why a create did not land. A name that stands is a version conflict
306/// naming the version the file is really at; anything else is storage's.
307fn refused_create(err: storage::StorageError, rel: &str, path: &Path) -> AxError {
308    if !matches!(err, storage::StorageError::NameTaken { .. }) {
309        return err.into_ax();
310    }
311    let standing = match std::fs::read(path) {
312        Ok(existing) => format!("{rel} already exists at {}, not new", version_of(&existing)),
313        Err(err) => format!("{rel} already exists, not new, and its version cannot be read: {err}"),
314    };
315    AxError::failure(AxCode::VersionConflict, "edit file", standing)
316        .with_recovery("read the file and edit against that version, or choose another path")
317}
318
319/// A minimal unified diff: enough to see what changed and where, and no
320/// dependency for the eighty lines it takes.
321fn unified_diff(path: &str, before: &str, after: &str) -> String {
322    let old_lines: Vec<&str> = before.lines().collect();
323    let new_lines: Vec<&str> = after.lines().collect();
324    let prefix = common_prefix(&old_lines, &new_lines);
325    let suffix = common_suffix(&old_lines, &new_lines, prefix);
326    let old_end = old_lines.len().saturating_sub(suffix);
327    let new_end = new_lines.len().saturating_sub(suffix);
328
329    let mut out = format!("--- a/{path}\n+++ b/{path}\n");
330    let old_count = old_end.saturating_sub(prefix);
331    let new_count = new_end.saturating_sub(prefix);
332    out.push_str(&format!(
333        "@@ -{},{} +{},{} @@\n",
334        prefix.saturating_add(1),
335        old_count,
336        prefix.saturating_add(1),
337        new_count
338    ));
339    for line in old_lines.get(prefix..old_end).unwrap_or_default() {
340        out.push_str(&format!("-{line}\n"));
341    }
342    for line in new_lines.get(prefix..new_end).unwrap_or_default() {
343        out.push_str(&format!("+{line}\n"));
344    }
345    out
346}
347
348fn common_prefix(old: &[&str], new: &[&str]) -> usize {
349    let mut i = 0usize;
350    while let (Some(a), Some(b)) = (old.get(i), new.get(i)) {
351        if a != b {
352            break;
353        }
354        i = i.saturating_add(1);
355    }
356    i
357}
358
359fn common_suffix(old: &[&str], new: &[&str], prefix: usize) -> usize {
360    let mut i = 0usize;
361    loop {
362        let old_left = old.len().saturating_sub(i);
363        let new_left = new.len().saturating_sub(i);
364        if old_left <= prefix || new_left <= prefix {
365            break;
366        }
367        let (Some(a), Some(b)) = (
368            old.get(old_left.saturating_sub(1)),
369            new.get(new_left.saturating_sub(1)),
370        ) else {
371            break;
372        };
373        if a != b {
374            break;
375        }
376        i = i.saturating_add(1);
377    }
378    i
379}
380
381#[cfg(test)]
382#[allow(
383    clippy::unwrap_used,
384    clippy::expect_used,
385    clippy::panic,
386    clippy::indexing_slicing,
387    reason = "test code"
388)]
389mod tests;