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