sprawling-runtime 0.0.11

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The edit tool: optimistic concurrency against a version the caller
//! must already have seen.
//!
//! Two refusals carry the design. A `base_version` that no longer
//! matches means the file moved under the caller — a blind overwrite
//! would erase whoever moved it, so the edit is refused with the
//! current version named. And an `old` string that matches zero times
//! or many times is refused with the count, because "replace the first
//! one" is a guess about which occurrence was meant.
//!
//! The echo is a unified diff. Per-edit diffs are the restoration
//! granularity: a checkpoint restores a wave, but a diff restores one
//! change, and that is the difference between undoing a mistake and
//! undoing an afternoon.

use std::path::{Path, PathBuf};

use kernel::{
    AxCode, AxError, B3Hash, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall,
    ToolMeta, ToolName, ToolOutcome,
};
use serde_json::{Map, Value};

pub struct EditTool {
    city_root: PathBuf,
    /// The run's write domain. Every model-chosen path is judged against
    /// it before the filesystem is touched.
    writable: kernel::WriteDomain,
    /// The run's policy cell, asked at each write for what the run may do
    /// to a file that already exists (`crates/runtime/spec/Tools.lean`
    /// §8-55, `crates/runtime/spec/PolicyTake.lean` §8-62).
    policy: crate::mode::PolicyReader,
    meta: ToolMeta,
}

/// The version stamp a caller must present: the content's whole
/// `cas:b3-<hex64>` Locator. `read` prints the same value and `plan
/// finish` takes it as evidence, so a model copies one string between
/// three tools and never shortens or completes it (kernel
/// `spec/Locator.lean` D27).
pub fn version_of(bytes: &[u8]) -> String {
    kernel::Locator::cas(B3Hash::digest(bytes)).to_string()
}

/// The version word that means "I expect this file to not exist yet".
/// A Locator can never spell it, so it cannot collide with a real
/// version.
const CREATES: &str = "new";

impl EditTool {
    /// The name this tool answers to, read by `mode::core_tools` too.
    pub const NAME: &'static str = "edit";

    pub fn new(
        city_root: &Path,
        domain: kernel::Address,
        writable: kernel::WriteDomain,
        policy: crate::mode::PolicyReader,
    ) -> Result<EditTool, AxError> {
        let params = Payload::of(&serde_json::json!({
            "type": "object",
            "properties": {
                "path": { "type": "string",
                    "description": "file to edit, relative to the city root" },
                "base_version": { "type": "string", "description":
                    "the `version` read printed for this file; refuses if it moved. Pass \"new\" to create the file" },
                "old": { "type": "string", "description":
                    "exact text to replace; must match exactly once. \"\" when creating" },
                "new": { "type": "string",
                    "description": "replacement text, or the whole file when creating" },
            },
            "required": ["path", "base_version", "old", "new"],
        }))?;
        Ok(EditTool {
            city_root: city_root.to_path_buf(),
            writable,
            policy,
            meta: ToolMeta {
                name: ToolName::parse(Self::NAME)?,
                // The write limit is not spelled here: it can change
                // while the run goes, and these bytes are frozen with the
                // tool list (§8-62); the note a change appends spells it.
                disclosure: "Replace an exact string in a file, guarded by the version you \
                             last saw: `old` must match exactly once or the call fails. A file \
                             that moved under you is refused and not overwritten; read it \
                             again and retry. A `base_version` of `new` creates the file."
                    .to_owned(),
                params,
                effect: Effect::Write { domain },
                cost_tier: CostTier::Light,
                timeout: None,
                render: RenderIntent::Diff {
                    locations: Vec::new(),
                },
                temporal: Temporal::Timeless,
            },
        })
    }
}

fn arg<'a>(args: &'a Map<String, Value>, key: &str) -> Result<&'a str, AxError> {
    args.get(key).and_then(Value::as_str).ok_or_else(|| {
        AxError::failure(
            AxCode::InvalidArgs,
            "edit file",
            format!("missing string argument `{key}`"),
        )
        .with_recovery("pass path, base_version, old and new - all strings")
    })
}

impl Tool for EditTool {
    fn meta(&self) -> &ToolMeta {
        &self.meta
    }

    /// The one file the call names. A path that does not parse wrote
    /// nothing, since `invoke` refuses it before touching the disk, but
    /// the answer stays `Domain` so that no reading of it can come out
    /// narrower than the truth.
    fn writes(&self, call: &ToolCall) -> kernel::Writes {
        match arg(call.args.as_map(), "path")
            .and_then(|asked| super::chosen_path::within_city(&self.city_root, asked, "edit file"))
            .and_then(|spelled| kernel::Address::parse(&spelled))
        {
            Ok(path) => kernel::Writes::Paths(vec![path]),
            Err(_) => kernel::Writes::Domain,
        }
    }

    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
        if call.name != self.meta.name {
            return Err(AxError::failure(
                AxCode::InvalidArgs,
                "edit file",
                format!("call routed to the wrong tool: {}", call.name.as_str()),
            )
            .with_recovery(format!(
                "call `{}`, the name this tool answers to",
                self.meta.name.as_str()
            )));
        }
        // Read once: every door below judges this call under one policy.
        let policy = self.policy.now();
        crate::mode::mode_admits(&policy, Self::NAME)?;
        let args = call.args.as_map();
        let spelled =
            super::chosen_path::within_city(&self.city_root, arg(args, "path")?, "edit file")?;
        let rel: &str = &spelled;
        let base_version = arg(args, "base_version")?;
        let old = arg(args, "old")?;
        let new = arg(args, "new")?;

        // The path is judged before the filesystem is touched, and by the
        // same two authorities everything else uses: Address::parse kills
        // traversal, and the write-domain gate judges the file the way
        // the bench judged the declared area. The bench only ever sees
        // the tool's own declaration; a model-chosen path is judged
        // here, and by the same door.
        let target = kernel::Address::parse(rel).map_err(|err| {
            AxError::failure(
                AxCode::InvalidArgs,
                "edit file",
                format!("{rel}: {}", err.subject()),
            )
            .with_recovery("pass a city-relative path with no `..` and no leading slash")
        })?;
        match kernel::gate::domain(&self.writable, &target, &kernel::TaintSet::empty()) {
            kernel::GateOutcome::Allow => {}
            // The write doors do not ask. Were one ever to, the call is
            // pending, not allowed.
            kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
            kernel::GateOutcome::Ask { question } => return Err(*question),
        }

        let path = self.city_root.join(rel);
        if base_version == CREATES {
            return self.create(rel, &path, old, new);
        }
        // Every arm below changes a file that is already there.
        match kernel::gate::replacing(policy.write, &target) {
            kernel::GateOutcome::Allow => {}
            kernel::GateOutcome::Deny { refusal } => return Err(*refusal),
            kernel::GateOutcome::Ask { question } => return Err(*question),
        }
        let bytes = std::fs::read(&path).map_err(|err| {
            AxError::failure(AxCode::InvalidArgs, "edit file", format!("{rel}: {err}"))
                .with_recovery(
                    "check the path, relative to the city root; to create a new file pass \
                     base_version:\"new\"",
                )
        })?;
        let current_version = version_of(&bytes);
        if current_version != base_version {
            return Err(AxError::failure(
                AxCode::VersionConflict,
                "edit file",
                format!("{rel} is at {current_version}, not {base_version}"),
            )
            .with_recovery("read the file again, then edit against the version you just saw"));
        }
        let text = String::from_utf8(bytes).map_err(|_| {
            AxError::failure(
                AxCode::InvalidArgs,
                "edit file",
                format!("{rel} is not valid UTF-8"),
            )
            .with_recovery(format!(
                "edit {rel} in a program that reads its bytes; this tool changes text \
                 files and this one is not text"
            ))
        })?;
        let hits = text.matches(old).count();
        if hits != 1 {
            return Err(AxError::failure(
                AxCode::InvalidArgs,
                "edit file",
                format!("`old` matches {hits} times in {rel}, not once"),
            )
            .with_recovery("include enough surrounding text to name exactly one occurrence"));
        }
        let updated = text.replacen(old, new, 1);
        // A name that is a link lands this write somewhere the gate
        // never judged - `.git/hooks` through a junction is privilege
        // escalation (`crates/storage/Spec.lean` §8-25). The replacement lands the way
        // every city write does: a staging file renamed over the name,
        // so a failed write leaves the old file and a hard link's other
        // names keep their bytes (`crates/storage/Spec.lean` §8-32).
        storage::WriteTarget::within("edit file", &self.city_root, &path)
            .and_then(|cleared| cleared.replace(updated.as_bytes()))
            .map_err(storage::StorageError::into_ax)?;
        let new_version = version_of(updated.as_bytes());

        let mut result = Map::new();
        result.insert("path".to_owned(), Value::String(rel.to_owned()));
        result.insert(
            "base_version".to_owned(),
            Value::String(base_version.to_owned()),
        );
        result.insert("new_version".to_owned(), Value::String(new_version));
        result.insert(
            "diff".to_owned(),
            Value::String(unified_diff(rel, &text, &updated)),
        );
        Ok(ToolOutcome {
            result: Payload::new(result)?,
            attachments: Vec::new(),
        })
    }
}

impl EditTool {
    /// The creating arm: the caller declared the file absent, so an
    /// existing file is a version conflict like any other - it names the
    /// version the file is really at.
    fn create(&self, rel: &str, path: &Path, old: &str, new: &str) -> Result<ToolOutcome, AxError> {
        if !old.is_empty() {
            return Err(AxError::failure(
                AxCode::InvalidArgs,
                "edit file",
                "creating a file replaces nothing: `old` must be \"\"",
            )
            .with_recovery("pass old:\"\" and the whole file in `new`"));
        }
        // The alias check runs before any directory is made: making a
        // parent through a link would land a directory inside the
        // protected metadata the link reaches, before the write itself
        // is refused (`crates/storage/Spec.lean` §8-25).
        storage::WriteTarget::within("edit file", &self.city_root, path)
            .map_err(|err| err.into_ax())?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|err| {
                AxError::failure(AxCode::StorageFatal, "edit file", format!("{rel}: {err}"))
                    .with_recovery(format!(
                        "free space on the disk holding the city, or remove the file \
                         standing where {rel}'s parent directory has to go"
                    ))
            })?;
        }
        // The filesystem claims the name at the write, so of two racing
        // creates one lands, under either limit (`crates/storage/Spec.lean` §8-32).
        storage::WriteTarget::within("edit file", &self.city_root, path)
            .and_then(|cleared| cleared.create(new.as_bytes()))
            .map_err(|err| refused_create(err, rel, path))?;
        let mut result = Map::new();
        result.insert("path".to_owned(), Value::String(rel.to_owned()));
        result.insert("base_version".to_owned(), Value::String(CREATES.to_owned()));
        result.insert(
            "new_version".to_owned(),
            Value::String(version_of(new.as_bytes())),
        );
        result.insert("diff".to_owned(), Value::String(unified_diff(rel, "", new)));
        Ok(ToolOutcome {
            result: Payload::new(result)?,
            attachments: Vec::new(),
        })
    }
}

/// Why a create did not land. A name that stands is a version conflict
/// naming the version the file is really at; anything else is storage's.
fn refused_create(err: storage::StorageError, rel: &str, path: &Path) -> AxError {
    if !matches!(err, storage::StorageError::NameTaken { .. }) {
        return err.into_ax();
    }
    let standing = match std::fs::read(path) {
        Ok(existing) => format!("{rel} already exists at {}, not new", version_of(&existing)),
        Err(err) => format!("{rel} already exists, not new, and its version cannot be read: {err}"),
    };
    AxError::failure(AxCode::VersionConflict, "edit file", standing)
        .with_recovery("read the file and edit against that version, or choose another path")
}

/// A minimal unified diff: enough to see what changed and where, and no
/// dependency for the eighty lines it takes.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
    let old_lines: Vec<&str> = before.lines().collect();
    let new_lines: Vec<&str> = after.lines().collect();
    let prefix = common_prefix(&old_lines, &new_lines);
    let suffix = common_suffix(&old_lines, &new_lines, prefix);
    let old_end = old_lines.len().saturating_sub(suffix);
    let new_end = new_lines.len().saturating_sub(suffix);

    let mut out = format!("--- a/{path}\n+++ b/{path}\n");
    let old_count = old_end.saturating_sub(prefix);
    let new_count = new_end.saturating_sub(prefix);
    out.push_str(&format!(
        "@@ -{},{} +{},{} @@\n",
        prefix.saturating_add(1),
        old_count,
        prefix.saturating_add(1),
        new_count
    ));
    for line in old_lines.get(prefix..old_end).unwrap_or_default() {
        out.push_str(&format!("-{line}\n"));
    }
    for line in new_lines.get(prefix..new_end).unwrap_or_default() {
        out.push_str(&format!("+{line}\n"));
    }
    out
}

fn common_prefix(old: &[&str], new: &[&str]) -> usize {
    let mut i = 0usize;
    while let (Some(a), Some(b)) = (old.get(i), new.get(i)) {
        if a != b {
            break;
        }
        i = i.saturating_add(1);
    }
    i
}

fn common_suffix(old: &[&str], new: &[&str], prefix: usize) -> usize {
    let mut i = 0usize;
    loop {
        let old_left = old.len().saturating_sub(i);
        let new_left = new.len().saturating_sub(i);
        if old_left <= prefix || new_left <= prefix {
            break;
        }
        let (Some(a), Some(b)) = (
            old.get(old_left.saturating_sub(1)),
            new.get(new_left.saturating_sub(1)),
        ) else {
            break;
        };
        if a != b {
            break;
        }
        i = i.saturating_add(1);
    }
    i
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    reason = "test code"
)]
#[cfg_attr(
    windows,
    expect(clippy::disallowed_methods, reason = "test fixture (child D4)")
)]
mod tests;