返回 CodeWhale
file.rs
根目录 / crates / tui / src / tools / file.rs
1 //! File system engines for the lowercase `read`, `write`, and `edit` primitives
2 //! plus deferred workspace helpers such as `list_dir`. The older `File`,
3 //! `read_file`, `write_file`, and `edit_file` names remain registered but hidden
4 //! so saved sessions can replay their original schemas and behavior.
5 //!
6 //! These tools provide safe file system operations within the workspace,
7 //! with path validation to prevent escaping the workspace boundary.
8
9 use super::diff_format::make_unified_diff;
10 use super::rust_format::{NORMALIZED_NOTE, normalize_edit};
11 use super::spec::{
12 ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult,
13 ToolSpec, lsp_diagnostics_for_paths, optional_str, optional_u64, required_str,
14 };
15 use super::syntax_check::guard_edit;
16 use async_trait::async_trait;
17 use serde_json::{Value, json};
18 use std::borrow::Cow;
19 use std::collections::HashMap;
20 use std::fs;
21 use std::path::{Path, PathBuf};
22 use std::sync::{Arc, Mutex, OnceLock, Weak};
23 use std::time::Duration;
24 use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
25 use tokio_util::sync::CancellationToken;
26 use unicode_normalization::UnicodeNormalization;
27
28 // === Content-hash edit guards (#3979) ===
29
30 /// Format a file snapshot's content hash as `sha256:<hex>`.
31 ///
32 /// The prefixed shape (rather than a bare hex digest) is deliberate: it is
33 /// self-describing in the transcript, and it makes an accidentally-truncated or
34 /// hand-invented value fail the equality check instead of matching by luck.
35 ///
36 /// A file's hash is taken over its raw bytes, always before any windowing,
37 /// truncation, or rendering, so the value `read` reports is the value `write`,
38 /// `edit`, and `patch` verify.
39 pub(super) fn content_hash(bytes: &[u8]) -> String {
40 format!("sha256:{}", crate::hashing::sha256_hex(bytes))
41 }
42
43 /// Hash a file's bytes without holding the whole file in memory.
44 ///
45 /// The read path streams a bounded window out of large files on purpose, so it
46 /// never has the full contents to hash. Digesting through a separate streaming
47 /// pass keeps that memory bound while still producing a hash over the entire
48 /// file — the only value an edit guard can verify against.
49 fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
50 use sha2::{Digest, Sha256};
51 use std::io::Read as _;
52
53 let mut file = fs::File::open(path)?;
54 let mut hasher = Sha256::new();
55 let mut buf = vec![0_u8; 64 * 1024];
56 loop {
57 let read = file.read(&mut buf)?;
58 if read == 0 {
59 break;
60 }
61 hasher.update(&buf[..read]);
62 }
63 Ok(format!(
64 "sha256:{}",
65 crate::hashing::hex_bytes(hasher.finalize())
66 ))
67 }
68
69 /// The one-line header that reports a snapshot hash to the model.
70 ///
71 /// This goes in `ToolResult::content`, not in `ToolResult::metadata`, and that
72 /// placement is the whole point. `metadata` never reaches the model: the wire
73 /// `ContentBlock::ToolResult` (`crates/core/src/request.rs`) has no field for
74 /// it, and the turn loop builds the tool message from `output.content` alone
75 /// (`crates/tui/src/core/engine/turn_loop.rs`). Metadata is for the TUI,
76 /// telemetry, and the approval/mutation receipts. A hash the model cannot read
77 /// is a guard the model cannot use, so it is rendered into the content — either
78 /// as an attribute on the `<file …>` envelope, or as this header line for the
79 /// unwrapped small-file read.
80 fn content_hash_header(hash: &str) -> String {
81 format!("content_hash=\"{hash}\"\n")
82 }
83
84 /// Reject a mutation whose `expected_hash` does not describe the current file.
85 ///
86 /// Callers must run this against the exact snapshot the mutation would be
87 /// applied to, and before anything is written. `None` (parameter absent) keeps
88 /// the pre-#3979 behavior untouched — the guard is opt-in.
89 fn verify_expected_hash(
90 expected: Option<&str>,
91 current_bytes: &[u8],
92 action: &str,
93 path_str: &str,
94 ) -> Result<(), ToolError> {
95 let Some(expected) = expected else {
96 return Ok(());
97 };
98 let actual = content_hash(current_bytes);
99 if expected == actual {
100 return Ok(());
101 }
102 Err(ToolError::execution_failed(format!(
103 "File `{action}` refused: {path_str} changed since it was read. \
104 expected_hash was {expected} but the file is now {actual}, so nothing was written. \
105 Recovery: call File with action=\"read\" path=\"{path_str}\" to get the current contents \
106 and its content_hash, then retry with the new hash."
107 )))
108 }
109
110 /// Shared schema text for the optional guard parameter.
111 ///
112 /// The hidden compatibility schema still has a byte budget, so this is only the
113 /// instruction the legacy caller needs: what to pass and what happens on a
114 /// mismatch. The rationale stays in doc comments rather than schema bytes.
115 pub(super) const EXPECTED_HASH_DESCRIPTION: &str = "The `content_hash` from a prior read; the write is refused and the file left unchanged if it changed since";
116
117 // === Cross-harness parameter aliases ===
118
119 /// Rewrite well-known parameter spellings from other coding harnesses onto the
120 /// names this tool actually implements.
121 ///
122 /// Every mainstream harness names the same three file-edit arguments
123 /// differently — `old_string`/`new_string`, `old_str`/`new_str`,
124 /// `oldText`/`newText` — and models carry whichever spelling their training
125 /// saw most. CodeWhale's canonical `search`/`replace` is the odd one out, so a
126 /// model reaching for its prior used to burn a full turn on a rejection
127 /// (#5209) and then guess again. Translating an unambiguous synonym is
128 /// strictly better than refusing it: the edit the model asked for is the edit
129 /// that happens, and the schema still advertises exactly one canonical name so
130 /// there is no new ambiguity to learn.
131 ///
132 /// This is deliberately *not* a silent-acceptance path. Only exact synonyms
133 /// are mapped, a synonym that disagrees with an explicitly supplied canonical
134 /// value is an error rather than a coin flip, and any parameter that is not a
135 /// known synonym still fails validation. The #5209 guarantee — no fabricated
136 /// "Replaced 1 occurrence" for an edit that never landed — is unchanged.
137 pub(super) struct ParamAlias {
138 /// Spelling a model might emit.
139 alias: &'static str,
140 /// Parameter this tool implements.
141 canonical: &'static str,
142 }
143
144 const fn alias(alias: &'static str, canonical: &'static str) -> ParamAlias {
145 ParamAlias { alias, canonical }
146 }
147
148 /// Path spellings shared by every file action. `path` is CodeWhale's
149 /// canonical name and the most common one in the field, but `file_path` is
150 /// widespread enough in training data to be worth accepting everywhere.
151 pub(super) const PATH_ALIASES: &[ParamAlias] =
152 &[alias("file_path", "path"), alias("filePath", "path")];
153
154 /// Edit-specific spellings. Ordered most- to least-common.
155 const EDIT_ALIASES: &[ParamAlias] = &[
156 alias("old_string", "search"),
157 alias("new_string", "replace"),
158 alias("old_str", "search"),
159 alias("new_str", "replace"),
160 alias("oldText", "search"),
161 alias("newText", "replace"),
162 alias("old_text", "search"),
163 alias("new_text", "replace"),
164 alias("replacement", "replace"),
165 ];
166
167 /// Read-window spellings. `offset`/`limit` and `line_offset`/`n_lines` both
168 /// name the same two numbers as CodeWhale's `start_line`/`max_lines` in widely
169 /// trained-on tool surfaces. A wrong guess here used to be ignored outright,
170 /// silently returning the head of the file instead of the window the model
171 /// asked for — a wrong answer shaped like a right one.
172 const READ_ALIASES: &[ParamAlias] = &[
173 alias("offset", "start_line"),
174 alias("line_offset", "start_line"),
175 alias("limit", "max_lines"),
176 alias("n_lines", "max_lines"),
177 alias("num_lines", "max_lines"),
178 ];
179
180 /// `search_name` spellings. The `File` wrapper advertises `max_results` for
181 /// both search actions, but only `search_content` implements that name; on
182 /// `search_name` the same number is spelled `limit`. Folding it here (rather
183 /// than copying it inside the wrapper) keeps one alias mechanism, so the
184 /// result-count cap a model asks for is the cap it gets whichever name it
185 /// reaches for, and a direct `file_search` call behaves the same way.
186 pub(super) const SEARCH_NAME_ALIASES: &[ParamAlias] = &[alias("max_results", "limit")];
187
188 /// `search_content` spellings, mirroring `SEARCH_NAME_ALIASES` in the other
189 /// direction: the wrapper advertises `query` and `limit` on the name-search
190 /// side, and a model that carries them across to a content search means
191 /// `pattern` and `max_results`.
192 pub(super) const SEARCH_CONTENT_ALIASES: &[ParamAlias] =
193 &[alias("query", "pattern"), alias("limit", "max_results")];
194
195 /// Apply `aliases` to `input`, in place.
196 ///
197 /// An alias is consumed only when the canonical key is absent. When both are
198 /// present and *equal* the alias is dropped as a harmless duplicate; when both
199 /// are present and disagree the call fails, because guessing which one the
200 /// model meant is exactly the fabrication this path exists to prevent.
201 pub(super) fn apply_param_aliases(
202 input: &mut Value,
203 aliases: &[ParamAlias],
204 tool_label: &str,
205 ) -> Result<(), ToolError> {
206 let Some(obj) = input.as_object_mut() else {
207 return Ok(());
208 };
209
210 for ParamAlias { alias, canonical } in aliases {
211 let Some(alias_value) = obj.remove(*alias) else {
212 continue;
213 };
214 match obj.get(*canonical) {
215 None => {
216 obj.insert((*canonical).to_string(), alias_value);
217 }
218 Some(existing) if existing == &alias_value => {}
219 Some(_) => {
220 return Err(ToolError::invalid_input(format!(
221 "{tool_label} received both `{canonical}` and its alias `{alias}` with different values, so the intended argument is ambiguous; nothing was changed. Pass only `{canonical}`."
222 )));
223 }
224 }
225 }
226
227 Ok(())
228 }
229
230 // === Per-action parameter contracts ===
231
232 /// The parameter contract for one `File` action.
233 ///
234 /// #5209 taught `edit` to refuse a parameter it does not implement instead of
235 /// dropping it and returning a success-shaped receipt. Only `edit` learned it.
236 /// Every other action kept silently discarding unknown keys, and for a reader
237 /// that is the same failure wearing a quieter costume: a misspelled
238 /// `start_line` on `read` is dropped, the head of the file comes back, and
239 /// nothing in the response says the requested window was never honored — a
240 /// wrong answer shaped like a right one.
241 ///
242 /// One table, one error shape, every action.
243 pub(super) struct ActionParams {
244 /// Action name as the model spells it on `File` (`read`, `write`, …).
245 action: &'static str,
246 /// Every parameter the action implements, canonical spellings only.
247 /// Aliases are folded onto these by [`apply_param_aliases`] before
248 /// validation runs, so they must not be listed here.
249 allowed: &'static [&'static str],
250 /// Parameters the action cannot run without.
251 required: &'static [&'static str],
252 /// `true` when exactly one of `required` is needed rather than all of
253 /// them — `patch` accepts `patch`, `replace`, or `changes`.
254 required_is_choice: bool,
255 }
256
257 const fn params(
258 action: &'static str,
259 allowed: &'static [&'static str],
260 required: &'static [&'static str],
261 ) -> ActionParams {
262 ActionParams {
263 action,
264 allowed,
265 required,
266 required_is_choice: false,
267 }
268 }
269
270 pub(super) const READ_PARAMS: ActionParams = params(
271 "read",
272 &["path", "start_line", "max_lines", "pages"],
273 &["path"],
274 );
275
276 pub(super) const WRITE_PARAMS: ActionParams = params(
277 "write",
278 &["path", "content", "expected_hash"],
279 &["path", "content"],
280 );
281
282 pub(super) const EDIT_PARAMS: ActionParams = params(
283 "edit",
284 &["path", "search", "replace", "expected_hash"],
285 &["path", "search", "replace"],
286 );
287
288 pub(super) const LIST_PARAMS: ActionParams = params("list", &["path"], &[]);
289
290 pub(super) const SEARCH_NAME_PARAMS: ActionParams = params(
291 "search_name",
292 &["query", "path", "limit", "extensions", "exclude"],
293 &["query"],
294 );
295
296 pub(super) const SEARCH_CONTENT_PARAMS: ActionParams = params(
297 "search_content",
298 &[
299 "pattern",
300 "path",
301 "include",
302 "exclude",
303 "context_lines",
304 "case_insensitive",
305 "max_results",
306 ],
307 &["pattern"],
308 );
309
310 pub(super) const PATCH_PARAMS: ActionParams = ActionParams {
311 action: "patch",
312 allowed: &[
313 "path",
314 "patch",
315 "replace",
316 "changes",
317 "fuzz",
318 "create_if_missing",
319 "expected_hash",
320 ],
321 required: &["patch", "replace", "changes"],
322 required_is_choice: true,
323 };
324
325 /// Render `names` as a backticked, comma-separated English list.
326 fn quoted_list(names: &[&str], conjunction: &str) -> String {
327 let quoted: Vec<String> = names.iter().map(|name| format!("`{name}`")).collect();
328 match quoted.as_slice() {
329 [] => "none".to_string(),
330 [only] => only.clone(),
331 [first, second] => format!("{first} {conjunction} {second}"),
332 [head @ .., last] => format!("{}, {conjunction} {last}", head.join(", ")),
333 }
334 }
335
336 impl ActionParams {
337 /// Reject parameter names this action does not implement.
338 ///
339 /// Must run *after* [`apply_param_aliases`], exactly as the `edit` path
340 /// does. The alias lane's reasoning stands: translating an unambiguous
341 /// synonym is better than refusing it, so by the time this runs every
342 /// spelling with a known meaning has already been folded onto its
343 /// canonical name. What is left is a name with no known meaning, where
344 /// continuing would mean guessing which argument was intended — so it
345 /// hard-errors rather than dropping the argument and reporting success.
346 pub(super) fn reject_unknown(&self, input: &Value) -> Result<(), ToolError> {
347 let action = self.action;
348 let required = if self.required_is_choice {
349 format!("one of {}", quoted_list(self.required, "or"))
350 } else {
351 quoted_list(self.required, "and")
352 };
353
354 let Some(obj) = input.as_object() else {
355 return Err(ToolError::invalid_input(format!(
356 "File {action} input must be an object. Allowed parameters are {}. Required: {required}. The {action} was not performed.",
357 quoted_list(self.allowed, "and"),
358 )));
359 };
360
361 let unexpected: Vec<&str> = obj
362 .keys()
363 .map(String::as_str)
364 .filter(|key| !self.allowed.contains(key))
365 .collect();
366 if !unexpected.is_empty() {
367 return Err(ToolError::invalid_input(format!(
368 "unexpected File {action} parameter(s): {}. Allowed parameters are {}. Required: {required}. The {action} was not performed.",
369 unexpected.join(", "),
370 quoted_list(self.allowed, "and"),
371 )));
372 }
373
374 Ok(())
375 }
376
377 /// A required parameter that is not also allowed would make the refusal
378 /// self-contradicting: it would name an argument the same check rejects.
379 #[cfg(test)]
380 pub(super) fn assert_required_is_allowed(&self) {
381 for name in self.required {
382 assert!(
383 self.allowed.contains(name),
384 "File {} requires `{name}` but does not allow it",
385 self.action
386 );
387 }
388 }
389 }
390
391 // === ReadFileTool ===
392
393 fn canonical_path_for_credential_guard(path: &Path) -> PathBuf {
394 fs::canonicalize(path).unwrap_or_else(|_| {
395 if path.is_absolute() {
396 path.to_path_buf()
397 } else {
398 std::env::current_dir()
399 .unwrap_or_else(|_| PathBuf::from("."))
400 .join(path)
401 }
402 })
403 }
404
405 fn config_backup_path_for_credential_guard(config_path: &Path) -> PathBuf {
406 let mut file_name = config_path
407 .file_name()
408 .map(std::ffi::OsString::from)
409 .unwrap_or_else(|| std::ffi::OsString::from(codewhale_config::CONFIG_FILE_NAME));
410 file_name.push(".bak");
411 config_path
412 .parent()
413 .unwrap_or_else(|| Path::new("."))
414 .join(file_name)
415 }
416
417 fn is_config_or_backup(candidate: &Path, config_path: &Path) -> bool {
418 let config_path = canonical_path_for_credential_guard(config_path);
419 let backup_path =
420 canonical_path_for_credential_guard(&config_backup_path_for_credential_guard(&config_path));
421 candidate == config_path || candidate == backup_path
422 }
423
424 /// Return whether `read_file` must refuse a CodeWhale-owned credential file.
425 ///
426 /// This is deliberately scoped to the active config, the two conventional
427 /// config locations (including one-time backups), and CodeWhale's file-backed
428 /// secret-store directories. Other dotfiles remain readable. Model-bound
429 /// redaction is still required because shell tools can read these files and
430 /// arbitrary commands can print credentials without reading a file at all.
431 /// Refuse a read the sandbox read deny-list blocks (S1).
432 ///
433 /// `read_file`, `read`, and `read_media` all run *in-process*: they call
434 /// `std::fs` inside the harness, so `sandbox-exec` and `bwrap` never see them
435 /// and the OS-level deny rules do not apply. This is the enforcement point for
436 /// those tools, and the refusal is always an explicit error — never an empty
437 /// result, which would read as "the file is empty" and invite the model to
438 /// probe siblings.
439 pub(crate) fn enforce_read_denylist(path: &Path, tool: &str) -> Result<(), ToolError> {
440 // Expand the user's home before authorization, retaining the spelling they
441 // supplied in every denial. This shares the file tools' path resolution;
442 // expansion grants no additional access and never exposes a symlink target.
443 let home_path = path
444 .to_str()
445 .map(super::spec::resolve_home_path)
446 .transpose()?
447 .flatten();
448 if home_path
449 .as_deref()
450 .is_some_and(is_codewhale_credential_path)
451 {
452 return Err(ToolError::permission_denied(format!(
453 "{tool} cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection"
454 )));
455 }
456 match crate::sandbox::read_guard::active().check(home_path.as_deref().unwrap_or(path)) {
457 Ok(()) => Ok(()),
458 Err(mut denial) => {
459 denial.requested = path.to_path_buf();
460 let message = denial.message(tool);
461 tracing::warn!(
462 target: "codewhale::sandbox::read_guard",
463 requested = %denial.requested.display(),
464 via_symlink = denial.via_symlink,
465 tool = tool,
466 "sandbox read deny-list refused a read"
467 );
468 Err(ToolError::permission_denied(message))
469 }
470 }
471 }
472
473 pub(crate) fn is_codewhale_credential_path(path: &Path) -> bool {
474 let candidate = canonical_path_for_credential_guard(path);
475
476 if let Ok(active_config) = codewhale_config::resolve_config_path(None)
477 && is_config_or_backup(&candidate, &active_config)
478 {
479 return true;
480 }
481
482 // `CODEWHALE_HOME` relocates the *runtime* home; it is not a licence to read
483 // the user's real `~/.codewhale/config.toml`. `codewhale_home()` returns the
484 // override when one is set, so relying on it alone left the ambient store
485 // unguarded whenever that variable pointed elsewhere. Keep the ambient root
486 // in the set alongside the override, mirroring the deliberately
487 // unconditional `~/.codewhale/secrets` entry in `sandbox::read_guard`
488 // (read_guard.rs:481-487). `legacy_deepseek_home()` is already ambient by
489 // construction (paths/src/lib.rs:183-185), so it needs no counterpart.
490 let mut roots: Vec<PathBuf> = Vec::with_capacity(3);
491 roots.extend(codewhale_config::codewhale_home().ok());
492 roots.extend(codewhale_config::legacy_deepseek_home().ok());
493 roots.extend(
494 codewhale_paths::user_home().map(|home| home.join(codewhale_config::CODEWHALE_APP_DIR)),
495 );
496 for root in roots {
497 if is_config_or_backup(&candidate, &root.join(codewhale_config::CONFIG_FILE_NAME)) {
498 return true;
499 }
500
501 let secrets_dir = canonical_path_for_credential_guard(&root.join("secrets"));
502 if candidate.starts_with(secrets_dir) {
503 return true;
504 }
505 }
506
507 false
508 }
509
510 // === small-contract-compatible primitive implementation helpers ===
511
512 /// Default model-visible byte budget for one `read` call.
513 ///
514 /// Bytes are the *only* default bound: there is no line cap, so an ordinary
515 /// source or prose file comes back whole in one call instead of being paged
516 /// at some arbitrary line count with most of the budget unspent.
517 const READ_DEFAULT_MAX_BYTES: usize = 100_000;
518 /// Hard ceiling on a budget the *model* asks for with `max_bytes`. A larger
519 /// request clamps down to this; it is never an error.
520 const READ_REQUEST_MAX_BYTES: usize = 500_000;
521 /// Outer bound on the operator's process-wide `[workshop] read_result_max_bytes`
522 /// override, and therefore on any read result.
523 const READ_RESULT_ABSOLUTE_MAX_BYTES: usize = 2 * 1024 * 1024;
524
525 /// Resolve the byte budget for one `read` call from the three layers that can
526 /// set it, highest wins:
527 ///
528 /// 1. **The model's own request** — `max_bytes` on this call, clamped to
529 /// [`READ_REQUEST_MAX_BYTES`] (500 000).
530 /// 2. **The operator's process-wide override** — `[workshop]
531 /// read_result_max_bytes`, clamped into
532 /// `[READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES]` (2 MiB).
533 /// 3. **The default** — [`READ_DEFAULT_MAX_BYTES`] (100 000).
534 ///
535 /// The result is `max(1, 2-or-3)`. Both raising layers can only raise: a model
536 /// request never shrinks a budget the operator widened, and an operator who
537 /// widened it process-wide keeps that floor when the model asks for less.
538 fn effective_read_max_bytes(requested: Option<usize>) -> usize {
539 let baseline =
540 crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
541 .map_or(READ_DEFAULT_MAX_BYTES, |configured| {
542 configured.clamp(READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES)
543 });
544 match requested {
545 Some(requested) => baseline.max(requested.min(READ_REQUEST_MAX_BYTES)),
546 None => baseline,
547 }
548 }
549
550 type FileMutationMutex = AsyncMutex<()>;
551
552 /// File primitives can also be invoked outside the native engine's global
553 /// execution lock (for example by an embedded host). Keep writes to one path
554 /// ordered in those hosts without exposing any locking ceremony in the tool
555 /// schema or result.
556 fn file_mutation_lock(path: &Path) -> Result<Arc<FileMutationMutex>, ToolError> {
557 static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<FileMutationMutex>>>> = OnceLock::new();
558 let locks = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
559 let mut locks = locks.lock().map_err(|_| {
560 ToolError::execution_failed(
561 "file mutation queue is unavailable because its lock was poisoned",
562 )
563 })?;
564 locks.retain(|_, lock| lock.strong_count() > 0);
565 if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
566 return Ok(lock);
567 }
568 let lock = Arc::new(AsyncMutex::new(()));
569 locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
570 Ok(lock)
571 }
572
573 async fn acquire_file_mutation(
574 path: &Path,
575 context: &ToolContext,
576 ) -> Result<OwnedMutexGuard<()>, ToolError> {
577 let lock = file_mutation_lock(path)?;
578 if let Some(cancel) = context.cancel_token.as_ref() {
579 tokio::select! {
580 guard = lock.lock_owned() => Ok(guard),
581 () = cancel.cancelled() => Err(ToolError::cancelled("Operation aborted")),
582 }
583 } else {
584 Ok(lock.lock_owned().await)
585 }
586 }
587
588 /// Atomic workspace write on the blocking pool: temp create plus fsync plus
589 /// rename (and a retry loop on Windows) must not park a Tokio worker
590 /// (blocking-call convention, #6149). Error shape matches the historical
591 /// inline call.
592 async fn run_blocking_write_atomic(path: &Path, contents: Vec<u8>) -> Result<(), ToolError> {
593 let path = path.to_path_buf();
594 tokio::task::spawn_blocking(move || {
595 crate::utils::write_atomic_workspace(&path, &contents).map_err(|e| {
596 ToolError::execution_failed(format!("Failed to write {}: {e}", path.display()))
597 })
598 })
599 .await
600 .map_err(|e| ToolError::execution_failed(format!("File write task: {e}")))??;
601 Ok(())
602 }
603
604 fn check_file_operation_cancelled(context: &ToolContext) -> Result<(), ToolError> {
605 if context
606 .cancel_token
607 .as_ref()
608 .is_some_and(CancellationToken::is_cancelled)
609 {
610 return Err(ToolError::cancelled("Operation aborted"));
611 }
612 Ok(())
613 }
614
615 async fn contract_mutation_result(
616 context: &ToolContext,
617 file_path: &Path,
618 requested_path: &str,
619 before: &str,
620 after: &str,
621 outcome: &str,
622 summary: String,
623 ) -> ToolResult {
624 let paths = [file_path.to_path_buf()];
625 let diagnostics = lsp_diagnostics_for_paths(context, &paths).await;
626 ToolResult::success(summary).with_metadata(json!({
627 "event": "file.mutation",
628 "lsp_diagnostics": diagnostics,
629 "mutation": {
630 "diff": make_unified_diff(requested_path, before, after),
631 "files": [{ "path": requested_path, "outcome": outcome }],
632 "renames": []
633 }
634 }))
635 }
636
637 fn reject_primitive_unknown(input: &Value, tool: &str, allowed: &[&str]) -> Result<(), ToolError> {
638 let object = input
639 .as_object()
640 .ok_or_else(|| ToolError::invalid_input(format!("{tool} input must be an object")))?;
641 let unexpected = object
642 .keys()
643 .filter(|key| !allowed.contains(&key.as_str()))
644 .cloned()
645 .collect::<Vec<_>>();
646 if unexpected.is_empty() {
647 return Ok(());
648 }
649 Err(ToolError::invalid_input(format!(
650 "unexpected {tool} parameter(s): {}",
651 unexpected.join(", ")
652 )))
653 }
654
655 fn contract_nonnegative_int(input: &Value, key: &str) -> Result<Option<usize>, ToolError> {
656 let Some(value) = input.get(key) else {
657 return Ok(None);
658 };
659 let number = value
660 .as_u64()
661 .ok_or_else(|| ToolError::invalid_input(format!("{key} must be a non-negative integer")))?;
662 usize::try_from(number)
663 .map(Some)
664 .map_err(|_| ToolError::invalid_input(format!("{key} exceeds platform range")))
665 }
666
667 fn primitive_image_mime(bytes: &[u8]) -> Option<&'static str> {
668 crate::image_attach::sniff_media_type(bytes)
669 .or_else(|| bytes.starts_with(b"BM").then_some("image/bmp"))
670 }
671
672 fn contract_format_size(bytes: usize) -> String {
673 if bytes < 1024 {
674 format!("{bytes}B")
675 } else if bytes < 1024 * 1024 {
676 format!("{:.1}KB", bytes as f64 / 1024.0)
677 } else {
678 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
679 }
680 }
681
682 #[derive(Debug)]
683 struct ContractReadWindow {
684 content: String,
685 shown_lines: usize,
686 truncated: bool,
687 first_line_too_large: bool,
688 }
689
690 /// Retain only complete lines from the head, stopping at `max_bytes`. A
691 /// terminal newline is content but does not add a phantom line to the
692 /// truncation counter.
693 ///
694 /// The byte budget is the single bound. There is no line cap to fragment a
695 /// file that fits: every retained line costs at least its own newline, so
696 /// `max_bytes` already bounds the line count as well.
697 fn contract_read_window(content: &str, max_bytes: usize) -> ContractReadWindow {
698 let mut lines = if content.is_empty() {
699 Vec::new()
700 } else {
701 content.split('\n').collect::<Vec<_>>()
702 };
703 if content.ends_with('\n') {
704 let _ = lines.pop();
705 }
706 if lines.first().is_some_and(|line| line.len() > max_bytes) {
707 return ContractReadWindow {
708 content: String::new(),
709 shown_lines: 0,
710 truncated: true,
711 first_line_too_large: true,
712 };
713 }
714
715 if content.len() <= max_bytes {
716 return ContractReadWindow {
717 content: content.to_string(),
718 shown_lines: lines.len(),
719 truncated: false,
720 first_line_too_large: false,
721 };
722 }
723
724 let mut kept = Vec::new();
725 let mut bytes = 0usize;
726 for line in &lines {
727 let next = line.len() + usize::from(!kept.is_empty());
728 if bytes.saturating_add(next) > max_bytes {
729 break;
730 }
731 kept.push(*line);
732 bytes += next;
733 }
734 let shown_lines = kept.len();
735 ContractReadWindow {
736 content: kept.join("\n"),
737 shown_lines,
738 truncated: true,
739 first_line_too_large: false,
740 }
741 }
742
743 /// Tool for reading UTF-8 files from the workspace.
744 pub struct ReadFileTool;
745
746 impl ReadFileTool {
747 /// Execute the lowercase `read` primitive without leaking the hidden
748 /// Codewhale hash/snapshot protocol into its small-contract-shaped model contract.
749 pub(super) async fn execute_contract_read(
750 input: Value,
751 context: &ToolContext,
752 ) -> Result<RichToolResult, ToolError> {
753 reject_primitive_unknown(&input, "read", &["path", "offset", "limit", "max_bytes"])?;
754 let path_str = required_str(&input, "path")?;
755 let offset = contract_nonnegative_int(&input, "offset")?;
756 let limit = contract_nonnegative_int(&input, "limit")?;
757 let max_bytes = effective_read_max_bytes(contract_nonnegative_int(&input, "max_bytes")?);
758 // S1/F2: check the caller's own spelling BEFORE `resolve_path`
759 // canonicalizes it. A workspace symlink `notes.txt` -> a denied vault
760 // file resolves to the secret's absolute location, and a denial raised
761 // only on the resolved path would name that location in the error —
762 // answering the very question ("where is the secret?") the read was
763 // probing for. `read_guard::check` canonicalizes internally, so the
764 // raw spelling still matches by its target; the resolved check after
765 // `resolve_path` stays as defense in depth for callers whose process
766 // cwd is not the workspace.
767 enforce_read_denylist(Path::new(path_str), "read")?;
768 let file_path = context.resolve_path(path_str)?;
769 if is_codewhale_credential_path(&file_path) {
770 return Err(ToolError::permission_denied(
771 "read cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection",
772 ));
773 }
774 enforce_read_denylist(&file_path, "read")?;
775 check_file_operation_cancelled(context)?;
776 let bytes = tokio::fs::read(&file_path).await.map_err(|error| {
777 ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display()))
778 })?;
779 // #6283: every read response carries the file's byte size, line
780 // count, and truncation flag so the caller can page deliberately
781 // instead of discovering a huge file one window at a time.
782 let size_bytes = bytes.len();
783 check_file_operation_cancelled(context)?;
784 if let Some(mime_type) = primitive_image_mime(&bytes) {
785 let prepared = crate::image_attach::prepare_tool_image_bytes(&bytes, mime_type);
786 context.note_file_read(&file_path);
787 return Ok(RichToolResult::with_content_blocks(
788 ToolResult::success(prepared.note).with_metadata(json!({
789 "evidence_routing": "inline"
790 })),
791 prepared.block.into_iter().collect(),
792 ));
793 }
794
795 // The small-contract reader decodes non-image buffers as UTF-8 text with replacement
796 // characters instead of refusing the whole read on one invalid byte.
797 let text = String::from_utf8_lossy(&bytes);
798 let all_lines = text.split('\n').collect::<Vec<_>>();
799 let requested_offset = offset.unwrap_or(1);
800 let start = requested_offset.saturating_sub(1);
801 if start >= all_lines.len() {
802 return Err(ToolError::execution_failed(format!(
803 "Offset {requested_offset} is beyond end of file ({} lines total)",
804 all_lines.len()
805 )));
806 }
807
808 let available = &all_lines[start..];
809 let selected = match limit {
810 Some(limit) => &available[..available.len().min(limit)],
811 None => available,
812 };
813 let selected_content = selected.join("\n");
814 let window = contract_read_window(&selected_content, max_bytes);
815 // Truncated means the file holds more than this response shows:
816 // either the byte budget cut the window, or a bounded range stopped
817 // before EOF. A whole file that fits is never truncated.
818 let truncated =
819 window.truncated || limit.is_some() && start + selected.len() < all_lines.len();
820 let first_display = start + 1;
821 let mut output = if window.first_line_too_large {
822 let size = selected.first().map_or(0, |line| line.len());
823 format!(
824 "[Line {first_display} is {}, exceeds the {max_bytes}-byte output budget for this call. Use bash: sed -n '{first_display}p' {path_str} | head -c {max_bytes}]",
825 contract_format_size(size)
826 )
827 } else {
828 window.content
829 };
830
831 if !window.first_line_too_large && window.truncated {
832 let last_display = first_display + window.shown_lines.saturating_sub(1);
833 let next_offset = last_display + 1;
834 // Continuation must be exact: name the next offset, and when the
835 // caller asked for a bounded range, the part of that range still
836 // unread. `max_bytes` is only offered while it can still go up.
837 let mut hint = format!("offset={next_offset}");
838 if let Some(limit) = limit {
839 let remaining = limit.saturating_sub(window.shown_lines);
840 if remaining > 0 {
841 hint.push_str(&format!(" limit={remaining}"));
842 }
843 }
844 let raise = if max_bytes < READ_REQUEST_MAX_BYTES {
845 format!(", or max_bytes up to {READ_REQUEST_MAX_BYTES} to read more per call")
846 } else {
847 String::new()
848 };
849 output.push_str(&format!(
850 "\n\n[Showing lines {first_display}-{last_display} of {} ({} total, {max_bytes}-byte output budget). Use {hint} to continue{raise}.]",
851 all_lines.len(),
852 contract_format_size(size_bytes)
853 ));
854 } else if limit.is_some() {
855 let consumed = selected.len();
856 if start + consumed < all_lines.len() {
857 let remaining = all_lines.len() - (start + consumed);
858 let next_offset = start + consumed + 1;
859 output.push_str(&format!(
860 "\n\n[{remaining} more lines in file ({} total). Use offset={next_offset} to continue.]",
861 contract_format_size(size_bytes)
862 ));
863 }
864 }
865
866 // This internal observation keeps hidden legacy edit replay working,
867 // but no hash or read-before-edit ceremony reaches the lowercase
868 // schema or result.
869 context.note_file_read(&file_path);
870 Ok(RichToolResult::plain(
871 ToolResult::success(output).with_metadata(json!({
872 "evidence_routing": "inline",
873 // The budget this call actually enforced. The context
874 // compactor honors it so an already-bounded read is never
875 // truncated a second time on its way into the conversation.
876 "read_budget_bytes": max_bytes,
877 // #6283: paging contract. `size` is the whole file in bytes,
878 // `line_count` its total lines, `truncated` whether the file
879 // holds more than this response shows.
880 "size": size_bytes,
881 "truncated": truncated,
882 "line_count": all_lines.len()
883 })),
884 ))
885 }
886 }
887
888 #[async_trait]
889 impl ToolSpec for ReadFileTool {
890 fn name(&self) -> &'static str {
891 "read_file"
892 }
893
894 fn model_visible(&self) -> bool {
895 false
896 }
897
898 fn description(&self) -> &'static str {
899 "Read a UTF-8 file from the workspace. Use this instead of `cat`, `head`, `tail`, or `sed -n '..p'` in `Bash` — it's faster, sandbox-aware, and skips the approval prompt. Plain text is returned as-is and records the file snapshot required before `edit` will make a narrow in-place edit. Text reads report the whole file's `content_hash=\"sha256:…\"`; pass that value back as `expected_hash` on a later `write`, `edit`, or `patch` to have the write refused if the file changed in between. Codewhale config files and file-backed credential stores cannot be read with this tool; use `codewhale config list` or `codewhale auth status` for safe inspection. PDFs are text-extracted when the optional `pdftotext` executable (Poppler) is installed. Image screenshots are OCR-extracted when local OCR is available. Cannot read other non-PDF binaries.\n\nFor large files, use `start_line` and `max_lines` to read in chunks. By default, returns up to 500 lines or 16KB, whichever comes first. If `truncated=\"true\"` and `next_start_line` is present, continue reading from there; a byte-limited window instead shows head + tail with a `[CONTENT TRUNCATED]` marker and its note says how to narrow the range. For PDFs, use `pages` instead — `start_line`/`max_lines` only apply to text files."
900 }
901
902 fn input_schema(&self) -> Value {
903 json!({
904 "type": "object",
905 "properties": {
906 "path": {
907 "type": "string",
908 "description": "Path to the file (relative to workspace, absolute, or ~/ home-relative). Alias: `file_path`"
909 },
910 "start_line": {
911 "type": "integer",
912 "description": "Starting line (1-based, default 1). Aliases: `offset`, `line_offset`"
913 },
914 "max_lines": {
915 "type": "integer",
916 "description": "Maximum lines to return (default 500, max 500; a 16KB byte budget applies regardless). Aliases: `limit`, `n_lines`"
917 },
918 "pages": {
919 "type": "string",
920 "description": "PDF only: page range to extract, e.g. \"1-5\" or \"10\". Ignored for non-PDF files."
921 }
922 },
923 "required": ["path"]
924 })
925 }
926
927 fn capabilities(&self) -> Vec<ToolCapability> {
928 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
929 }
930
931 fn supports_parallel(&self) -> bool {
932 true
933 }
934
935 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
936 let mut input = input;
937 apply_param_aliases(&mut input, PATH_ALIASES, "File read")?;
938 apply_param_aliases(&mut input, READ_ALIASES, "File read")?;
939 READ_PARAMS.reject_unknown(&input)?;
940
941 let path_str = required_str(&input, "path")?;
942 // S1/F2: raw spelling first, resolved path after — see the matching
943 // comment in `execute_contract_read`. Only the raw-spelling denial can
944 // promise an error that never names the symlink target's location.
945 enforce_read_denylist(Path::new(path_str), "read_file")?;
946 let file_path = context.resolve_path(path_str)?;
947 if is_codewhale_credential_path(&file_path) {
948 return Err(ToolError::permission_denied(
949 "File `read` cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection",
950 ));
951 }
952 enforce_read_denylist(&file_path, "read_file")?;
953 let pages = optional_str(&input, "pages")?;
954
955 if let Some(result) = read_pdf_if_detected(
956 &file_path,
957 pages,
958 super::pdf::PdfTextCommand::system(context.cancel_token.as_ref()),
959 )
960 .await?
961 {
962 return Ok(result);
963 }
964 if is_image_for_ocr(&file_path) {
965 // OCR shells out to tesseract (or runs a Vision pass): the blocking
966 // subprocess call stays on the blocking pool (blocking-call
967 // convention, #6149).
968 let file_path = file_path.clone();
969 let requested_path = path_str.to_string();
970 return tokio::task::spawn_blocking(move || {
971 read_image_via_ocr(&file_path, &requested_path)
972 })
973 .await
974 .map_err(|e| ToolError::execution_failed(format!("Image OCR task: {e}")))?;
975 }
976
977 // Open before parameter parsing so a missing file keeps the
978 // historical "Failed to read …" error shape regardless of the other
979 // arguments. The open and size probe run on the blocking pool —
980 // tool handlers execute on the Tokio runtime (blocking-call
981 // convention, #6149).
982 let file_bytes = tokio::task::spawn_blocking({
983 let file_path = file_path.clone();
984 move || {
985 let file = fs::File::open(&file_path).map_err(|e| {
986 ToolError::execution_failed(format!(
987 "Failed to read {}: {}",
988 file_path.display(),
989 e
990 ))
991 })?;
992 Ok::<_, ToolError>(file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX))
993 }
994 })
995 .await
996 .map_err(|e| ToolError::execution_failed(format!("File open task: {e}")))??;
997
998 let explicit_range = input
999 .get("start_line")
1000 .or_else(|| input.get("max_lines"))
1001 .is_some();
1002
1003 // Small-file fast path. Only applies when the caller didn't pass an
1004 // explicit range — otherwise an explicit `start_line = 5` on a
1005 // tiny file would silently ignore the request.
1006 if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 {
1007 let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
1008 ToolError::execution_failed(format!(
1009 "Failed to read {}: {}",
1010 file_path.display(),
1011 e
1012 ))
1013 })?;
1014 context.note_file_read(&file_path);
1015
1016 let total_lines = contents.lines().count();
1017 if total_lines <= SMALL_FILE_LINES {
1018 // The whole file is in hand, so hash it directly rather than
1019 // re-reading it. Prefixed as a header line because this branch
1020 // returns the contents unwrapped — there is no `<file …>` tag
1021 // to hang the attribute on.
1022 let hash = content_hash(contents.as_bytes());
1023 let body = format!("{}{contents}", content_hash_header(&hash));
1024 return Ok(ToolResult::success(body).with_metadata(json!({
1025 "evidence_routing": "inline",
1026 "content_hash": hash
1027 })));
1028 }
1029
1030 // Small in bytes but too many lines: render the default window
1031 // straight from the in-memory contents.
1032 let hash = content_hash(contents.as_bytes());
1033 let window: Vec<String> = contents
1034 .lines()
1035 .take(DEFAULT_READ_LINES)
1036 .map(str::to_string)
1037 .collect();
1038 return Ok(render_line_window(
1039 path_str,
1040 &window,
1041 total_lines,
1042 1,
1043 DEFAULT_READ_LINES,
1044 Some(hash.as_str()),
1045 ));
1046 }
1047
1048 // Strict types (2026-08-04 review): a `start_line:"1200"` string or a
1049 // negative/float value used to silently fall back to the defaults —
1050 // returning the head of the file instead of the window the model
1051 // asked for, the exact wrong-answer-shaped-like-a-right-one this
1052 // action's alias/unknown-parameter hardening exists to prevent.
1053 let start_line = match optional_u64(&input, "start_line", 1)? {
1054 0 => {
1055 return Err(ToolError::invalid_input(
1056 "start_line must be 1-based and greater than 0".to_string(),
1057 ));
1058 }
1059 v => usize::try_from(v).map_err(|_| {
1060 ToolError::invalid_input(
1061 "start_line exceeds platform addressable range".to_string(),
1062 )
1063 })?,
1064 };
1065
1066 let max_lines = match optional_u64(&input, "max_lines", DEFAULT_READ_LINES as u64)? {
1067 0 => {
1068 return Err(ToolError::invalid_input(
1069 "max_lines must be greater than 0".to_string(),
1070 ));
1071 }
1072 v => {
1073 let converted = usize::try_from(v).map_err(|_| {
1074 ToolError::invalid_input(
1075 "max_lines exceeds platform addressable range".to_string(),
1076 )
1077 })?;
1078 std::cmp::min(converted, HARD_MAX_READ_LINES)
1079 }
1080 };
1081
1082 // Bounded read for ranged/large files: skip and take lines through a
1083 // BufReader instead of materializing the whole file. The stream still
1084 // runs to EOF so the total line count and whole-file UTF-8 validation
1085 // match the historical read_to_string behavior. Open, stream, and hash
1086 // all run on the blocking pool (blocking-call convention, #6149).
1087 let (window, total_lines, hash) = tokio::task::spawn_blocking({
1088 let file_path = file_path.clone();
1089 move || {
1090 let file = fs::File::open(&file_path).map_err(|e| {
1091 ToolError::execution_failed(format!(
1092 "Failed to read {}: {}",
1093 file_path.display(),
1094 e
1095 ))
1096 })?;
1097 let (window, total_lines) = read_window_streaming(file, start_line, max_lines)
1098 .map_err(|e| {
1099 ToolError::execution_failed(format!(
1100 "Failed to read {}: {}",
1101 file_path.display(),
1102 e
1103 ))
1104 })?;
1105 // The window is a slice; the guard needs the whole file. A
1106 // second streaming pass digests the rest without ever
1107 // materializing it. A failure here only costs the guard — the
1108 // read itself already succeeded, so the window is still
1109 // returned, just without a hash to pass back to `edit`.
1110 // Special files are skipped: reopening a FIFO or device can
1111 // block indefinitely (or re-consume a one-shot stream), and a
1112 // stream has no stable content an edit guard could pin.
1113 let hash = match fs::metadata(&file_path) {
1114 Ok(meta) if meta.is_file() => hash_file_streaming(&file_path).ok(),
1115 _ => None,
1116 };
1117 Ok::<_, ToolError>((window, total_lines, hash))
1118 }
1119 })
1120 .await
1121 .map_err(|e| ToolError::execution_failed(format!("File read task: {e}")))??;
1122 context.note_file_read(&file_path);
1123
1124 // `start_line > total_lines` is not an error — it lets the model
1125 // page past the end without raising. Returns an empty-content
1126 // sentinel so subsequent reads can stop.
1127 if start_line > total_lines {
1128 let hash_attr = hash
1129 .as_deref()
1130 .map(|hash| format!(" content_hash=\"{hash}\""))
1131 .unwrap_or_default();
1132 let output = format!(
1133 "<file path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"none\" truncated=\"false\"{hash_attr}>\n\
1134 \n\
1135 [NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n\
1136 </file>"
1137 );
1138 return Ok(ToolResult::success(output).with_metadata(json!({
1139 "evidence_routing": "inline",
1140 "content_hash": hash
1141 })));
1142 }
1143
1144 Ok(render_line_window(
1145 path_str,
1146 &window,
1147 total_lines,
1148 start_line,
1149 max_lines,
1150 hash.as_deref(),
1151 ))
1152 }
1153 }
1154
1155 // Bounded output for large files. The small-file fast path keeps the
1156 // historical "return contents unchanged" behavior so existing flows
1157 // (small configs, single source files, etc.) don't suddenly start
1158 // seeing wrapped output. Once a file is large or the caller asks
1159 // for an explicit range, we switch to a numbered, line-tagged
1160 // window with continuation hints so the model can page through
1161 // without re-loading the entire file on every turn. Harvested
1162 // from PR #1451 by @Oliver-ZPLiu, closes part of #1450.
1163 // One bound, not two competing ones. The real cost of a read is BYTES of
1164 // context, and `MAX_VISIBLE_BYTES` already enforces that. A separate 200-line
1165 // default fired long before the byte budget on any prose file — a 229-line,
1166 // 12 KB document truncated at line 200 with a third of the budget unspent,
1167 // costing a second round trip to fetch 29 lines. The line cap now only guards
1168 // pathologically short lines, where 500 lines is still a small read.
1169 const DEFAULT_READ_LINES: usize = HARD_MAX_READ_LINES;
1170 const HARD_MAX_READ_LINES: usize = 500;
1171 const MAX_VISIBLE_BYTES: usize = 16 * 1024;
1172 const SMALL_FILE_LINES: usize = HARD_MAX_READ_LINES;
1173 const SMALL_FILE_BYTES: usize = 16 * 1024;
1174
1175 /// Stream a line window out of `file`: skip `start_line - 1` lines, collect
1176 /// up to `max_lines`, then keep counting (and validating UTF-8) to EOF.
1177 /// Returns the collected window plus the total line count. Only the window
1178 /// is ever held in memory.
1179 fn read_window_streaming(
1180 file: fs::File,
1181 start_line: usize,
1182 max_lines: usize,
1183 ) -> std::io::Result<(Vec<String>, usize)> {
1184 use std::io::BufRead;
1185
1186 let mut reader = std::io::BufReader::new(file);
1187 let mut raw: Vec<u8> = Vec::new();
1188 let mut window: Vec<String> = Vec::new();
1189 let mut total_lines = 0usize;
1190 let start_idx = start_line - 1;
1191
1192 loop {
1193 raw.clear();
1194 let n = reader.read_until(b'\n', &mut raw)?;
1195 if n == 0 {
1196 break;
1197 }
1198 // Mirror `str::lines`: strip the trailing '\n', and a '\r' only when
1199 // it directly precedes that '\n'.
1200 let mut end = raw.len();
1201 if raw[..end].ends_with(b"\n") {
1202 end -= 1;
1203 if raw[..end].ends_with(b"\r") {
1204 end -= 1;
1205 }
1206 }
1207 // Validate every line so invalid UTF-8 anywhere in the file fails
1208 // exactly like the previous whole-file read_to_string did.
1209 let line = std::str::from_utf8(&raw[..end]).map_err(|_| {
1210 std::io::Error::new(
1211 std::io::ErrorKind::InvalidData,
1212 "stream did not contain valid UTF-8",
1213 )
1214 })?;
1215 if total_lines >= start_idx && window.len() < max_lines {
1216 window.push(line.to_string());
1217 }
1218 total_lines += 1;
1219 }
1220
1221 Ok((window, total_lines))
1222 }
1223
1224 /// Marker placed between the retained head and tail when a read window is
1225 /// truncated by the byte budget. Mirrors qwen-code's truncation style so the
1226 /// model sees both ends of the range.
1227 const BYTE_TRUNCATION_SEPARATOR: &str = "\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n";
1228
1229 /// Split `content` into a head of at most `head_budget` bytes and a tail that
1230 /// fills the remainder of `total_budget` (separator accounted for). Never
1231 /// overlaps and never splits mid-codepoint. Style matches qwen-code:
1232 /// `head_budget = total_budget / 5`.
1233 fn head_tail_for_budget(content: &str, total_budget: usize) -> (String, String) {
1234 let head_budget = (total_budget / 5).max(1);
1235 let head_end = (0..=head_budget.min(content.len()))
1236 .rev()
1237 .find(|&i| content.is_char_boundary(i))
1238 .unwrap_or(0);
1239 let sep_len = BYTE_TRUNCATION_SEPARATOR.len();
1240 let tail_budget = total_budget
1241 .saturating_sub(head_end)
1242 .saturating_sub(sep_len)
1243 .max(1);
1244 let tail_floor = content.len().saturating_sub(tail_budget).max(head_end);
1245 let tail_start = (tail_floor..=content.len())
1246 .find(|&i| content.is_char_boundary(i))
1247 .unwrap_or(content.len());
1248 (
1249 content[..head_end].to_string(),
1250 content[tail_start..].to_string(),
1251 )
1252 }
1253
1254 /// Render a collected line window into the `<file …>` wrapper used for
1255 /// ranged/large reads. `window` must hold the lines for
1256 /// `start_line..start_line + max_lines` (clamped to EOF).
1257 fn render_line_window(
1258 path_str: &str,
1259 window: &[String],
1260 total_lines: usize,
1261 start_line: usize,
1262 max_lines: usize,
1263 content_hash: Option<&str>,
1264 ) -> ToolResult {
1265 let zero_based_start = start_line - 1;
1266 let zero_based_end = std::cmp::min(zero_based_start + max_lines, total_lines);
1267 let shown_first = start_line;
1268 let shown_last = zero_based_end; // 1-based inclusive line number of the last shown line
1269
1270 let mut numbered = String::new();
1271 for (offset, line) in window.iter().enumerate() {
1272 let line_no = start_line + offset;
1273 numbered.push_str(&format!("{line_no:>6}│ {line}\n"));
1274 }
1275
1276 // UTF-8-safe byte truncation of the rendered range. Qwen-style: keep a
1277 // short head (budget/5) plus the matching tail so the model sees both
1278 // ends of a long range. The full file already lives at `path_str` — the
1279 // recovery note names that absolute/workspace path for a re-read.
1280 let visible_bytes =
1281 crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
1282 .map(|n| n.clamp(MAX_VISIBLE_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES))
1283 .unwrap_or(MAX_VISIBLE_BYTES);
1284 let truncated_by_bytes = numbered.len() > visible_bytes;
1285 let shown_content = if truncated_by_bytes {
1286 let (head, tail) = head_tail_for_budget(&numbered, visible_bytes);
1287 format!("{head}{BYTE_TRUNCATION_SEPARATOR}{tail}")
1288 } else {
1289 numbered
1290 };
1291
1292 let truncated_by_lines = zero_based_end < total_lines;
1293 let truncated = truncated_by_lines || truncated_by_bytes;
1294 let next_start = zero_based_end + 1;
1295
1296 let mut attrs = format!(
1297 "path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"{shown_first}-{shown_last}\" truncated=\"{truncated}\""
1298 );
1299 if truncated_by_lines {
1300 attrs.push_str(&format!(" next_start_line=\"{next_start}\""));
1301 }
1302 // Hashes the whole file, not the shown window — a partial read still
1303 // yields a guard the model can pass to `edit`/`patch`.
1304 if let Some(hash) = content_hash {
1305 attrs.push_str(&format!(" content_hash=\"{hash}\""));
1306 }
1307
1308 let mut output = format!("<file {attrs}>\n{shown_content}");
1309 if truncated_by_lines {
1310 output.push_str(&format!(
1311 "\n[TRUNCATED] Showing lines {shown_first}-{shown_last} of {total_lines}. To continue, call read with path=\"{path_str}\" offset={next_start} limit={max_lines}\n"
1312 ));
1313 }
1314 if truncated_by_bytes {
1315 if shown_first == shown_last {
1316 // One line alone exceeds the byte budget: no start_line/max_lines
1317 // combination can ever reveal the elided middle, so the note must
1318 // not pretend otherwise — name the escape hatch that works.
1319 output.push_str(&format!(
1320 "\n[TRUNCATED] Line {shown_first} alone exceeds the {visible_bytes}-byte output budget; showing its head + tail. No line window can reveal the middle of one line — use a searched shell slice when needed.\n"
1321 ));
1322 } else {
1323 let narrower = (shown_last - shown_first).div_ceil(2).max(1);
1324 output.push_str(&format!(
1325 "\n[TRUNCATED] The selected range exceeded the {visible_bytes}-byte output budget; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. offset={shown_first} limit={narrower}, then advance offset.\n"
1326 ));
1327 }
1328 }
1329 output.push_str("</file>");
1330
1331 // The file tool self-bounds at its own byte budget and carries its own continuation
1332 // contract (`next_start_line`), so the large-output spillover envelope
1333 // must never re-wrap a read result with a second, weaker truncation.
1334 ToolResult::success(output).with_metadata(json!({
1335 "evidence_routing": "inline",
1336 "content_hash": content_hash
1337 }))
1338 }
1339
1340 fn read_image_via_ocr(path: &Path, requested_path: &str) -> Result<ToolResult, ToolError> {
1341 let text = crate::tools::image_ocr::ocr_image_path(path)?;
1342 Ok(ToolResult::success(format!(
1343 "<image_ocr path=\"{requested_path}\">\n{text}\n</image_ocr>"
1344 )))
1345 }
1346
1347 /// Detect an existing PDF by extension or by sniffing `%PDF` magic bytes.
1348 async fn is_pdf(path: &Path) -> Result<bool, ToolError> {
1349 let extension_matches = path
1350 .extension()
1351 .and_then(|e| e.to_str())
1352 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"));
1353 let mut file = tokio::fs::File::open(path).await.map_err(|error| {
1354 ToolError::execution_failed(format!("Failed to read {}: {error}", path.display()))
1355 })?;
1356 if extension_matches {
1357 return Ok(true);
1358 }
1359 let mut buf = [0u8; 4];
1360 use tokio::io::AsyncReadExt;
1361 Ok(file.read_exact(&mut buf).await.is_ok() && &buf == b"%PDF")
1362 }
1363
1364 fn is_image_for_ocr(path: &Path) -> bool {
1365 path.extension()
1366 .and_then(|e| e.to_str())
1367 .is_some_and(|ext| {
1368 matches!(
1369 ext.to_ascii_lowercase().as_str(),
1370 "png" | "jpg" | "jpeg" | "tif" | "tiff" | "bmp"
1371 )
1372 })
1373 }
1374
1375 fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
1376 let trimmed = spec.trim();
1377 if trimmed.is_empty() {
1378 return None;
1379 }
1380 if let Some((a, b)) = trimmed.split_once('-') {
1381 let start: u32 = a.trim().parse().ok()?;
1382 let end: u32 = b.trim().parse().ok()?;
1383 if start == 0 || end < start {
1384 return None;
1385 }
1386 Some((start, end))
1387 } else {
1388 let n: u32 = trimmed.parse().ok()?;
1389 if n == 0 {
1390 return None;
1391 }
1392 Some((n, n))
1393 }
1394 }
1395
1396 /// Clean PDF-extracted text for TUI display: collapse consecutive blank
1397 /// lines (more than 1 becomes 1), replace NUL bytes with U+FFFD, replace
1398 /// non-breaking spaces with regular spaces, and trim trailing whitespace
1399 /// on each line. Produces output that won't clutter the transcript with
1400 /// vertical gaps or invisible control characters.
1401 fn clean_pdf_text(raw: &str) -> String {
1402 let mut out = String::with_capacity(raw.len());
1403 let mut blank_run = 0usize;
1404 let mut any_content = false;
1405 for line in raw.lines() {
1406 let trimmed = line.trim_end();
1407 if trimmed.is_empty() {
1408 blank_run = blank_run.saturating_add(1);
1409 if blank_run <= 1 {
1410 out.push('\n');
1411 }
1412 } else {
1413 blank_run = 0;
1414 any_content = true;
1415 // Push cleaned characters directly — avoids a per-line
1416 // temporary String allocation.
1417 for c in trimmed.chars() {
1418 match c {
1419 '\0' => out.push('\u{FFFD}'),
1420 '\u{A0}' => out.push(' '),
1421 other => out.push(other),
1422 }
1423 }
1424 out.push('\n');
1425 }
1426 }
1427 // Trim leading blank lines only — don't use str::trim() which
1428 // would also strip intentional indentation (e.g. centred titles).
1429 if any_content {
1430 let start = out.find(|c: char| c != '\n').unwrap_or(0);
1431 // Walk back from end to find the last non-newline character.
1432 let end = out.rfind(|c: char| c != '\n').map_or(out.len(), |i| {
1433 i + out[i..].chars().next().map_or(1, |c| c.len_utf8())
1434 });
1435 out[start..end].to_string()
1436 } else {
1437 String::new()
1438 }
1439 }
1440
1441 async fn read_pdf_if_detected(
1442 path: &Path,
1443 pages: Option<&str>,
1444 command: super::pdf::PdfTextCommand<'_>,
1445 ) -> Result<Option<ToolResult>, ToolError> {
1446 if !is_pdf(path).await? {
1447 return Ok(None);
1448 }
1449 // Validate the `pages` spec once, up front, so both extractor paths
1450 // surface the same error shape on bad input.
1451 let page_range = match pages {
1452 Some(spec) => match parse_pages_arg(spec) {
1453 Some((start, end)) => Some((start, end)),
1454 None => {
1455 return Err(ToolError::invalid_input(format!(
1456 "invalid `pages` value `{spec}` (expected `N` or `N-M`, e.g. `1-5`)"
1457 )));
1458 }
1459 },
1460 None => None,
1461 };
1462
1463 read_pdf_with_command(path, page_range, command)
1464 .await
1465 .map(Some)
1466 }
1467
1468 async fn read_pdf_with_command(
1469 path: &Path,
1470 page_range: Option<(u32, u32)>,
1471 command: super::pdf::PdfTextCommand<'_>,
1472 ) -> Result<ToolResult, ToolError> {
1473 let text = super::pdf::extract_path(path, page_range, command)
1474 .await
1475 .map_err(super::pdf::into_tool_error)?;
1476 Ok(ToolResult::success(clean_pdf_text(&text)))
1477 }
1478
1479 // === WriteFileTool ===
1480
1481 /// Tool for writing UTF-8 files to the workspace.
1482 pub struct WriteFileTool;
1483
1484 impl WriteFileTool {
1485 /// Execute the small-contract-shaped lowercase writer. Compatibility-only hash
1486 /// arguments remain on the hidden `write_file`/`File` paths.
1487 pub(super) async fn execute_contract_write(
1488 input: Value,
1489 context: &ToolContext,
1490 ) -> Result<ToolResult, ToolError> {
1491 reject_primitive_unknown(&input, "write", &["path", "content"])?;
1492 let path_str = required_str(&input, "path")?;
1493 let file_content = required_str(&input, "content")?;
1494 let file_path = context.resolve_path(path_str)?;
1495 let mutation_guard = acquire_file_mutation(&file_path, context).await?;
1496 check_file_operation_cancelled(context)?;
1497
1498 let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false);
1499 let prior_bytes = if existed_before {
1500 tokio::fs::read(&file_path).await.unwrap_or_default()
1501 } else {
1502 Vec::new()
1503 };
1504 let prior_contents = String::from_utf8_lossy(&prior_bytes);
1505
1506 if let Some(parent) = file_path.parent() {
1507 tokio::fs::create_dir_all(parent).await.map_err(|error| {
1508 ToolError::execution_failed(format!(
1509 "Failed to create directory {}: {error}",
1510 parent.display()
1511 ))
1512 })?;
1513 }
1514 check_file_operation_cancelled(context)?;
1515 // Preserve the existing file's line-ending style on overwrite (see
1516 // `preserve_prior_line_endings`); otherwise a CRLF (Windows) file is
1517 // silently rewritten with LF line endings.
1518 let mut written = preserve_prior_line_endings(file_content, &prior_contents);
1519 guard_edit(
1520 &file_path,
1521 path_str,
1522 existed_before.then(|| prior_contents.as_ref()),
1523 &written,
1524 )?;
1525 if existed_before
1526 && let Some(normalized) = normalize_edit(&file_path, &prior_contents, &written).await
1527 {
1528 written = normalized;
1529 }
1530 run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
1531 check_file_operation_cancelled(context)?;
1532 context.note_file_read(&file_path);
1533 drop(mutation_guard);
1534
1535 let outcome = if existed_before { "updated" } else { "created" };
1536 let utf16_units = written.encode_utf16().count();
1537 Ok(contract_mutation_result(
1538 context,
1539 &file_path,
1540 path_str,
1541 prior_contents.as_ref(),
1542 &written,
1543 outcome,
1544 format!("Successfully wrote {utf16_units} bytes to {path_str}"),
1545 )
1546 .await)
1547 }
1548 }
1549
1550 #[async_trait]
1551 impl ToolSpec for WriteFileTool {
1552 fn name(&self) -> &'static str {
1553 "write_file"
1554 }
1555
1556 fn model_visible(&self) -> bool {
1557 false
1558 }
1559
1560 fn description(&self) -> &'static str {
1561 "Write content to a UTF-8 file in the workspace. Use this instead of heredocs (`cat <<EOF > file`) or `echo > file` in `Bash` — diffs render inline and approval is handled cleanly. Creates or overwrites; parent directories are auto-created. Pass `expected_hash` (the `content_hash` from a prior `read`) to have the overwrite refused if the file changed since that read."
1562 }
1563
1564 fn input_schema(&self) -> Value {
1565 json!({
1566 "type": "object",
1567 "properties": {
1568 "path": {
1569 "type": "string",
1570 "description": "Path to the file. Alias: `file_path`"
1571 },
1572 "content": {
1573 "type": "string",
1574 "description": "Content to write"
1575 },
1576 "expected_hash": {
1577 "type": "string",
1578 "description": EXPECTED_HASH_DESCRIPTION
1579 }
1580 },
1581 "required": ["path", "content"]
1582 })
1583 }
1584
1585 fn capabilities(&self) -> Vec<ToolCapability> {
1586 vec![
1587 ToolCapability::WritesFiles,
1588 ToolCapability::Sandboxable,
1589 ToolCapability::RequiresApproval,
1590 ]
1591 }
1592
1593 fn approval_requirement(&self) -> ApprovalRequirement {
1594 ApprovalRequirement::Suggest
1595 }
1596
1597 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1598 let mut input = input;
1599 apply_param_aliases(&mut input, PATH_ALIASES, "File write")?;
1600 WRITE_PARAMS.reject_unknown(&input)?;
1601
1602 let path_str = required_str(&input, "path")?;
1603 let file_content = required_str(&input, "content")?;
1604 let expected_hash = optional_str(&input, "expected_hash")?;
1605
1606 let file_path = context.resolve_path(path_str)?;
1607
1608 // Snapshot the existing contents (if any) before we overwrite — used
1609 // to render an inline diff in the tool result.
1610 let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false);
1611 let prior_contents = if existed_before {
1612 tokio::fs::read_to_string(&file_path)
1613 .await
1614 .unwrap_or_default()
1615 } else {
1616 String::new()
1617 };
1618
1619 // Content-hash guard (#3979), checked against the same snapshot the
1620 // diff is rendered from and before any directory or file is touched.
1621 if let Some(expected) = expected_hash {
1622 if !existed_before {
1623 // A hash describes a file that was read. Guarding a create is
1624 // a contradiction, and silently creating the file anyway would
1625 // defeat the guard the caller asked for — fail closed.
1626 return Err(ToolError::execution_failed(format!(
1627 "File `write` refused: expected_hash was supplied but {path_str} does not exist, so there is no snapshot to verify and nothing was written. Recovery: drop `expected_hash` to create the file, or read the intended path first."
1628 )));
1629 }
1630 verify_expected_hash(Some(expected), prior_contents.as_bytes(), "write", path_str)?;
1631 }
1632
1633 // Create parent directories if needed
1634 if let Some(parent) = file_path.parent() {
1635 tokio::fs::create_dir_all(parent).await.map_err(|e| {
1636 ToolError::execution_failed(format!(
1637 "Failed to create directory {}: {}",
1638 parent.display(),
1639 e
1640 ))
1641 })?;
1642 }
1643
1644 // Preserve the existing file's line-ending style on overwrite (see
1645 // `preserve_prior_line_endings`); a full `write_file` over a CRLF
1646 // (Windows) file otherwise silently rewrites every line ending to LF.
1647 let mut written = preserve_prior_line_endings(file_content, &prior_contents);
1648
1649 guard_edit(
1650 &file_path,
1651 path_str,
1652 existed_before.then(|| prior_contents.as_ref()),
1653 &written,
1654 )?;
1655 if existed_before
1656 && let Some(normalized) = normalize_edit(&file_path, &prior_contents, &written).await
1657 {
1658 written = normalized;
1659 }
1660
1661 run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
1662 context.note_file_read(&file_path);
1663
1664 let display = file_path.display().to_string();
1665 let diff = make_unified_diff(&display, &prior_contents, &written);
1666 let summary = if existed_before {
1667 format!("Wrote {} bytes to {}", written.len(), display)
1668 } else {
1669 format!("Created {} ({} bytes)", display, written.len())
1670 };
1671 let body = if diff.is_empty() {
1672 format!("{summary}\n(no changes)")
1673 } else {
1674 format!("{diff}\n{summary}")
1675 };
1676
1677 // Append LSP diagnostics for the written file when enabled (#428).
1678 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
1679 let full_body = if diag_block.is_empty() {
1680 body
1681 } else {
1682 format!("{body}\n{diag_block}")
1683 };
1684
1685 let outcome = if existed_before { "updated" } else { "created" };
1686 // Keep the execution-owned receipt workspace-relative even though the
1687 // legacy model-facing output above retains its resolved-path wording.
1688 let receipt_diff = make_unified_diff(path_str, &prior_contents, &written);
1689 Ok(ToolResult::success(full_body).with_metadata(json!({
1690 "event": "file.mutation",
1691 "mutation": {
1692 "diff": receipt_diff,
1693 "files": [{ "path": path_str, "outcome": outcome }],
1694 "renames": []
1695 }
1696 })))
1697 }
1698 }
1699
1700 // === EditFileTool ===
1701
1702 /// Tool for search/replace editing of files.
1703 pub struct EditFileTool;
1704
1705 #[derive(Clone, Debug)]
1706 struct ContractEdit {
1707 index: usize,
1708 old_text: String,
1709 new_text: String,
1710 }
1711
1712 #[derive(Clone, Debug)]
1713 struct ResolvedContractEdit {
1714 index: usize,
1715 start: usize,
1716 end: usize,
1717 replacement: String,
1718 }
1719
1720 fn normalize_contract_line_endings(text: &str) -> String {
1721 text.replace("\r\n", "\n").replace('\r', "\n")
1722 }
1723
1724 fn contract_line_ending(text: &str) -> &'static str {
1725 match text.find('\n') {
1726 Some(index) if index > 0 && text.as_bytes()[index - 1] == b'\r' => "\r\n",
1727 _ => "\n",
1728 }
1729 }
1730
1731 fn restore_contract_line_endings(text: &str, ending: &str) -> String {
1732 if ending == "\r\n" {
1733 text.replace('\n', "\r\n")
1734 } else {
1735 text.to_string()
1736 }
1737 }
1738
1739 /// Rewrite `content` to match the line-ending style of an existing file's
1740 /// `prior` content, so a full-file overwrite (`write_file` / contract `write`)
1741 /// does not silently flip a CRLF (Windows) file to LF — the same policy
1742 /// `edit_file` applies. A brand-new file (no prior content) is returned
1743 /// verbatim: there is no style to preserve.
1744 fn preserve_prior_line_endings(content: &str, prior: &str) -> String {
1745 if prior.is_empty() {
1746 return content.to_string();
1747 }
1748 restore_contract_line_endings(
1749 &normalize_contract_line_endings(content),
1750 contract_line_ending(prior),
1751 )
1752 }
1753
1754 /// Fallback matching view used only after a literal match fails. It follows
1755 /// The small-contract normalization categories while leaving the public schema as
1756 /// exact-text replacement rather than teaching a second edit mode.
1757 fn normalize_contract_fuzzy(text: &str) -> String {
1758 let compatible = text.nfkc().collect::<String>();
1759 compatible
1760 .split('\n')
1761 .map(str::trim_end)
1762 .collect::<Vec<_>>()
1763 .join("\n")
1764 .chars()
1765 .map(|ch| match ch {
1766 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
1767 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
1768 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
1769 | '\u{2212}' => '-',
1770 '\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
1771 other => other,
1772 })
1773 .collect()
1774 }
1775
1776 fn text_matches(haystack: &str, needle: &str) -> Vec<(usize, usize)> {
1777 if needle.is_empty() {
1778 return Vec::new();
1779 }
1780 haystack
1781 .match_indices(needle)
1782 .map(|(start, matched)| (start, start + matched.len()))
1783 .collect()
1784 }
1785
1786 fn contract_edit_not_found(path: &str, index: usize, total: usize) -> ToolError {
1787 if total == 1 {
1788 ToolError::execution_failed(format!(
1789 "Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines."
1790 ))
1791 } else {
1792 ToolError::execution_failed(format!(
1793 "Could not find edits[{index}] in {path}. The oldText must match exactly including all whitespace and newlines."
1794 ))
1795 }
1796 }
1797
1798 fn contract_edit_duplicate(path: &str, index: usize, total: usize, matches: usize) -> ToolError {
1799 if total == 1 {
1800 ToolError::execution_failed(format!(
1801 "Found {matches} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique."
1802 ))
1803 } else {
1804 ToolError::execution_failed(format!(
1805 "Found {matches} occurrences of edits[{index}] in {path}. Each oldText must be unique. Please provide more context to make it unique."
1806 ))
1807 }
1808 }
1809
1810 fn prepare_contract_edit_input(mut input: Value) -> Result<Value, ToolError> {
1811 let object = input
1812 .as_object_mut()
1813 .ok_or_else(|| ToolError::invalid_input("edit input must be an object"))?;
1814 if let Some(Value::String(encoded)) = object.get("edits")
1815 && let Ok(decoded) = serde_json::from_str::<Value>(encoded)
1816 && decoded.is_array()
1817 {
1818 object.insert("edits".to_string(), decoded);
1819 }
1820
1821 let legacy_old = object
1822 .get("oldText")
1823 .and_then(Value::as_str)
1824 .map(str::to_string);
1825 let legacy_new = object
1826 .get("newText")
1827 .and_then(Value::as_str)
1828 .map(str::to_string);
1829 if let (Some(old_text), Some(new_text)) = (legacy_old, legacy_new) {
1830 let legacy = json!({"oldText": old_text, "newText": new_text});
1831 let mut edits = object
1832 .get("edits")
1833 .and_then(Value::as_array)
1834 .cloned()
1835 .unwrap_or_default();
1836 edits.push(legacy);
1837 object.insert("edits".to_string(), Value::Array(edits));
1838 object.remove("oldText");
1839 object.remove("newText");
1840 }
1841 Ok(input)
1842 }
1843
1844 fn parse_contract_edits(input: &Value) -> Result<Vec<ContractEdit>, ToolError> {
1845 let raw = input
1846 .get("edits")
1847 .and_then(Value::as_array)
1848 .ok_or_else(|| ToolError::invalid_input("edits must be an array"))?;
1849 if raw.is_empty() {
1850 return Err(ToolError::invalid_input(
1851 "edit requires at least one replacement in edits",
1852 ));
1853 }
1854 raw.iter()
1855 .enumerate()
1856 .map(|(index, edit)| {
1857 reject_primitive_unknown(edit, &format!("edits[{index}]"), &["oldText", "newText"])?;
1858 let old_text = required_str(edit, "oldText")?;
1859 let new_text = required_str(edit, "newText")?;
1860 if old_text.is_empty() {
1861 return Err(ToolError::invalid_input(format!(
1862 "edits[{index}].oldText must not be empty"
1863 )));
1864 }
1865 Ok(ContractEdit {
1866 index,
1867 old_text: normalize_contract_line_endings(old_text),
1868 new_text: normalize_contract_line_endings(new_text),
1869 })
1870 })
1871 .collect()
1872 }
1873
1874 fn apply_resolved_edits(base: &str, edits: &[ResolvedContractEdit], offset: usize) -> String {
1875 let mut updated = base.to_string();
1876 for edit in edits.iter().rev() {
1877 updated.replace_range(
1878 edit.start.saturating_sub(offset)..edit.end.saturating_sub(offset),
1879 &edit.replacement,
1880 );
1881 }
1882 updated
1883 }
1884
1885 fn lines_with_endings(text: &str) -> Vec<&str> {
1886 if text.is_empty() {
1887 Vec::new()
1888 } else {
1889 text.split_inclusive('\n').collect()
1890 }
1891 }
1892
1893 fn line_spans(text: &str) -> Vec<(usize, usize)> {
1894 let mut offset = 0usize;
1895 lines_with_endings(text)
1896 .into_iter()
1897 .map(|line| {
1898 let span = (offset, offset + line.len());
1899 offset = span.1;
1900 span
1901 })
1902 .collect()
1903 }
1904
1905 fn touched_line_range(
1906 spans: &[(usize, usize)],
1907 edit: &ResolvedContractEdit,
1908 ) -> Result<(usize, usize), ToolError> {
1909 let start = spans
1910 .iter()
1911 .position(|(line_start, line_end)| edit.start >= *line_start && edit.start < *line_end)
1912 .ok_or_else(|| ToolError::execution_failed("edit match fell outside the file"))?;
1913 let mut end = start;
1914 while end < spans.len() && spans[end].1 < edit.end {
1915 end += 1;
1916 }
1917 if end >= spans.len() {
1918 return Err(ToolError::execution_failed(
1919 "edit match fell outside the file",
1920 ));
1921 }
1922 Ok((start, end + 1))
1923 }
1924
1925 fn apply_fuzzy_edits_preserving_other_lines(
1926 original: &str,
1927 normalized: &str,
1928 edits: &[ResolvedContractEdit],
1929 ) -> Result<String, ToolError> {
1930 let original_lines = lines_with_endings(original);
1931 let spans = line_spans(normalized);
1932 if original_lines.len() != spans.len() {
1933 return Err(ToolError::execution_failed(
1934 "fuzzy edit could not preserve the file's untouched lines",
1935 ));
1936 }
1937
1938 #[derive(Debug)]
1939 struct Group {
1940 start_line: usize,
1941 end_line: usize,
1942 edits: Vec<ResolvedContractEdit>,
1943 }
1944
1945 let mut groups: Vec<Group> = Vec::new();
1946 for edit in edits {
1947 let (start_line, end_line) = touched_line_range(&spans, edit)?;
1948 if let Some(group) = groups.last_mut()
1949 && start_line < group.end_line
1950 {
1951 group.end_line = group.end_line.max(end_line);
1952 group.edits.push(edit.clone());
1953 } else {
1954 groups.push(Group {
1955 start_line,
1956 end_line,
1957 edits: vec![edit.clone()],
1958 });
1959 }
1960 }
1961
1962 let mut result = String::new();
1963 let mut original_line = 0usize;
1964 for group in groups {
1965 for line in &original_lines[original_line..group.start_line] {
1966 result.push_str(line);
1967 }
1968 let group_start = spans[group.start_line].0;
1969 let group_end = spans[group.end_line - 1].1;
1970 result.push_str(&apply_resolved_edits(
1971 &normalized[group_start..group_end],
1972 &group.edits,
1973 group_start,
1974 ));
1975 original_line = group.end_line;
1976 }
1977 for line in &original_lines[original_line..] {
1978 result.push_str(line);
1979 }
1980 Ok(result)
1981 }
1982
1983 fn apply_contract_edits(
1984 base: &str,
1985 edits: &[ContractEdit],
1986 path: &str,
1987 ) -> Result<String, ToolError> {
1988 let fuzzy_base = normalize_contract_fuzzy(base);
1989 let initial = edits
1990 .iter()
1991 .map(|edit| {
1992 if base.contains(&edit.old_text) {
1993 Ok(false)
1994 } else if fuzzy_base.contains(&normalize_contract_fuzzy(&edit.old_text)) {
1995 Ok(true)
1996 } else {
1997 Err(contract_edit_not_found(path, edit.index, edits.len()))
1998 }
1999 })
2000 .collect::<Result<Vec<_>, _>>()?;
2001 let use_fuzzy = initial.into_iter().any(|used| used);
2002 let replacement_base = if use_fuzzy { fuzzy_base.as_str() } else { base };
2003
2004 let mut resolved = Vec::with_capacity(edits.len());
2005 for edit in edits {
2006 let exact = text_matches(replacement_base, &edit.old_text);
2007 let fuzzy_old = normalize_contract_fuzzy(&edit.old_text);
2008 let fuzzy_occurrences = text_matches(&fuzzy_base, &fuzzy_old).len();
2009 if fuzzy_occurrences > 1 {
2010 return Err(contract_edit_duplicate(
2011 path,
2012 edit.index,
2013 edits.len(),
2014 fuzzy_occurrences,
2015 ));
2016 }
2017 let matches = if exact.is_empty() {
2018 text_matches(replacement_base, &fuzzy_old)
2019 } else {
2020 exact
2021 };
2022 let Some(&(start, end)) = matches.first() else {
2023 return Err(contract_edit_not_found(path, edit.index, edits.len()));
2024 };
2025 if matches.len() > 1 {
2026 return Err(contract_edit_duplicate(
2027 path,
2028 edit.index,
2029 edits.len(),
2030 matches.len(),
2031 ));
2032 }
2033 resolved.push(ResolvedContractEdit {
2034 index: edit.index,
2035 start,
2036 end,
2037 replacement: edit.new_text.clone(),
2038 });
2039 }
2040
2041 resolved.sort_by_key(|edit| (edit.start, edit.end));
2042 for pair in resolved.windows(2) {
2043 if pair[0].end > pair[1].start {
2044 return Err(ToolError::execution_failed(format!(
2045 "edits[{}] and edits[{}] overlap in {path}; merge them or target separate regions",
2046 pair[0].index, pair[1].index
2047 )));
2048 }
2049 }
2050
2051 let updated = if use_fuzzy {
2052 apply_fuzzy_edits_preserving_other_lines(base, replacement_base, &resolved)?
2053 } else {
2054 apply_resolved_edits(replacement_base, &resolved, 0)
2055 };
2056 if updated == base {
2057 return Err(ToolError::execution_failed(format!(
2058 "No changes made to {path}; the replacement produced identical content."
2059 )));
2060 }
2061 Ok(updated)
2062 }
2063
2064 impl EditFileTool {
2065 pub(super) async fn execute_contract_edits(
2066 input: Value,
2067 context: &ToolContext,
2068 ) -> Result<ToolResult, ToolError> {
2069 let input = prepare_contract_edit_input(input)?;
2070 reject_primitive_unknown(&input, "edit", &["path", "edits"])?;
2071 let path_str = required_str(&input, "path")?;
2072 let edits = parse_contract_edits(&input)?;
2073 let file_path = context.resolve_path(path_str)?;
2074 let mutation_guard = acquire_file_mutation(&file_path, context).await?;
2075 check_file_operation_cancelled(context)?;
2076
2077 tokio::fs::OpenOptions::new()
2078 .read(true)
2079 .write(true)
2080 .open(&file_path)
2081 .await
2082 .map_err(|error| {
2083 ToolError::execution_failed(format!(
2084 "Could not edit file {path_str}: target must be readable and writable ({error})"
2085 ))
2086 })?;
2087 check_file_operation_cancelled(context)?;
2088 let raw_bytes = tokio::fs::read(&file_path).await.map_err(|error| {
2089 ToolError::execution_failed(format!("Could not edit file {path_str}: {error}"))
2090 })?;
2091 check_file_operation_cancelled(context)?;
2092 let raw = String::from_utf8_lossy(&raw_bytes).into_owned();
2093 let (bom, without_bom) = raw
2094 .strip_prefix('\u{FEFF}')
2095 .map_or(("", raw.as_str()), |text| ("\u{FEFF}", text));
2096 let ending = contract_line_ending(without_bom);
2097 let normalized = normalize_contract_line_endings(without_bom);
2098 let updated = apply_contract_edits(&normalized, &edits, path_str)?;
2099 check_file_operation_cancelled(context)?;
2100 let mut final_content = format!("{bom}{}", restore_contract_line_endings(&updated, ending));
2101 guard_edit(&file_path, path_str, Some(&raw), &final_content)?;
2102 if let Some(normalized) = normalize_edit(&file_path, &raw, &final_content).await {
2103 final_content = normalized;
2104 }
2105
2106 run_blocking_write_atomic(&file_path, final_content.clone().into_bytes()).await?;
2107 check_file_operation_cancelled(context)?;
2108 context.note_file_read(&file_path);
2109 drop(mutation_guard);
2110
2111 Ok(contract_mutation_result(
2112 context,
2113 &file_path,
2114 path_str,
2115 &raw,
2116 &final_content,
2117 "updated",
2118 format!(
2119 "Successfully replaced {} block(s) in {path_str}.",
2120 edits.len()
2121 ),
2122 )
2123 .await)
2124 }
2125 }
2126
2127 #[async_trait]
2128 impl ToolSpec for EditFileTool {
2129 fn name(&self) -> &'static str {
2130 "edit_file"
2131 }
2132
2133 fn model_visible(&self) -> bool {
2134 false
2135 }
2136
2137 fn description(&self) -> &'static str {
2138 "Replace text in a single file via exact search/replace after the file has been read with File `read` in this session. Use this instead of `sed -i` in `Bash` for one unambiguous in-place edit. `search` must match exactly one location by default; when no exact match is found the tool retries with leading-whitespace-tolerant fuzzy matching automatically. Returns a compact unified diff, not the full file. Pass `expected_hash` (the `content_hash` from that `read`) to have the edit refused, with the file untouched, if it changed in between. For structural, multi-block, or cross-file changes, use File `patch` or `write` instead."
2139 }
2140
2141 fn input_schema(&self) -> Value {
2142 json!({
2143 "type": "object",
2144 "properties": {
2145 "path": {
2146 "type": "string",
2147 "description": "Path to the file. Alias: `file_path`"
2148 },
2149 "search": {
2150 "type": "string",
2151 "description": "Exact text to search for, including whitespace, indentation, and newlines. Aliases: `old_string`, `old_str`, `oldText`"
2152 },
2153 "replace": {
2154 "type": "string",
2155 "description": "Text to replace with. Aliases: `new_string`, `new_str`, `newText`"
2156 },
2157 "expected_hash": {
2158 "type": "string",
2159 "description": EXPECTED_HASH_DESCRIPTION
2160 }
2161 },
2162 "required": ["path", "search", "replace"]
2163 })
2164 }
2165
2166 fn capabilities(&self) -> Vec<ToolCapability> {
2167 vec![
2168 ToolCapability::WritesFiles,
2169 ToolCapability::Sandboxable,
2170 ToolCapability::RequiresApproval,
2171 ]
2172 }
2173
2174 fn approval_requirement(&self) -> ApprovalRequirement {
2175 ApprovalRequirement::Suggest
2176 }
2177
2178 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
2179 // Translate known cross-harness spellings (`old_string`/`new_string`,
2180 // `old_str`/`new_str`, …) onto `search`/`replace` first, then reject
2181 // whatever is left that we do not implement. #5209 required that a
2182 // mis-named edit never produce a success-shaped receipt for a file
2183 // that did not change; performing the edit the model unambiguously
2184 // asked for satisfies that more directly than refusing it did.
2185 let mut input = input;
2186 apply_param_aliases(&mut input, PATH_ALIASES, "File edit")?;
2187 apply_param_aliases(&mut input, EDIT_ALIASES, "File edit")?;
2188 EDIT_PARAMS.reject_unknown(&input)?;
2189
2190 let path_str = required_str(&input, "path")?;
2191 let search = required_str(&input, "search")?;
2192 let replace = required_str(&input, "replace")?;
2193 let expected_hash = optional_str(&input, "expected_hash")?;
2194
2195 if search == replace {
2196 // #5003 — long-text edits repeatedly failed here because the model
2197 // generated a `replace` identical to `search`. A bare "no change"
2198 // message gave no hint of the root cause, so the model retried the
2199 // same broken call. Spell out the failure and the recovery path.
2200 let char_count = search.chars().count();
2201 let line_count = search.lines().count();
2202 return Err(ToolError::invalid_input(format!(
2203 "search and replace are identical ({char_count} chars, {line_count} lines), so no change is possible. This usually means `replace` was copied verbatim from `search` instead of carrying the intended edits. Recovery: re-read the file with File action=\"read\", then retry with a `replace` that is genuinely different from `search`; for large multi-line rewrites prefer apply_patch with a unified diff."
2204 )));
2205 }
2206 if search.is_empty() {
2207 return Err(ToolError::invalid_input("search must not be empty"));
2208 }
2209 if let Some(reason) = edit_payload_looks_corrupted(search, replace) {
2210 return Err(ToolError::invalid_input(format!(
2211 "edit_file refused corrupted payload: {reason}. Recovery: re-read the file and retry with a complete replace (or use apply_patch for brace-heavy multi-line edits)."
2212 )));
2213 }
2214
2215 let file_path = context.resolve_path(path_str)?;
2216 context.require_fresh_file_read(&file_path, path_str)?;
2217
2218 let contents = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
2219 ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
2220 })?;
2221
2222 // Content-hash guard (#3979). Verified against `contents` — the exact
2223 // snapshot every match below is computed from and that the write is
2224 // derived from — and before any search/replace work, so a stale hash
2225 // can never reach the filesystem regardless of what the search would
2226 // have matched.
2227 verify_expected_hash(expected_hash, contents.as_bytes(), "edit", path_str)?;
2228
2229 // Models provide LF newlines even when the file on disk uses CRLF.
2230 // Match in a newline-normalized view, while retaining the sparse
2231 // positions where CR bytes were removed so only the original span is
2232 // replaced and the rest of the file stays byte-for-byte untouched.
2233 let (normalized_contents, crlf_positions) = normalize_crlf_with_positions(&contents);
2234 let normalized_search = normalize_crlf(search);
2235 let mut exact_ranges = normalized_contents
2236 .match_indices(normalized_search.as_ref())
2237 .map(|(start, matched)| (start, start + matched.len()));
2238 let first_exact_match = exact_ranges
2239 .next()
2240 .map(|range| map_normalized_range(range, crlf_positions.as_deref()));
2241 let exact_count = usize::from(first_exact_match.is_some()) + exact_ranges.count();
2242
2243 let ((match_start, match_end), fuzz_kind) = if exact_count == 0 {
2244 // First fallback: tolerate indentation differences.
2245 let indent_matches = map_normalized_ranges(
2246 leading_whitespace_fuzzy_matches(
2247 normalized_contents.as_ref(),
2248 normalized_search.as_ref(),
2249 ),
2250 crlf_positions.as_deref(),
2251 );
2252 match indent_matches.as_slice() {
2253 [(start, end)] => ((*start, *end), Some("indentation")),
2254 [] => {
2255 // Second fallback: tolerate typographic-punctuation
2256 // drift (smart quotes, em-dashes, NBSP). Picks up the
2257 // copy-paste failure mode where a browser/chat client
2258 // silently substituted Unicode punctuation in for the
2259 // ASCII the file actually contains.
2260 let punct_matches = map_normalized_ranges(
2261 punctuation_normalized_matches(
2262 normalized_contents.as_ref(),
2263 normalized_search.as_ref(),
2264 ),
2265 crlf_positions.as_deref(),
2266 );
2267 match punct_matches.as_slice() {
2268 [] => {
2269 // #5003 — the model could not tell why its search
2270 // missed; show the first lines of the search text
2271 // so it can compare against the file's contents.
2272 return Err(ToolError::execution_failed(format!(
2273 "Search string not found in {}. The search text starts with:\n{}\nRecovery: call File with action=\"read\" path=\"{path_str}\" to inspect the current contents, then retry with a search string copied from the file.",
2274 file_path.display(),
2275 preview_search_for_error(search),
2276 )));
2277 }
2278 [(start, end)] => ((*start, *end), Some("punctuation")),
2279 _ => {
2280 return Err(ToolError::execution_failed(format!(
2281 "File `edit` search is non-unique after punctuation normalization: matched {} locations in {}. Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
2282 punct_matches.len(),
2283 file_path.display()
2284 )));
2285 }
2286 }
2287 }
2288 _ => {
2289 return Err(ToolError::execution_failed(format!(
2290 "File `edit` search is non-unique after indentation normalization: matched {} locations in {}. Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
2291 indent_matches.len(),
2292 file_path.display()
2293 )));
2294 }
2295 }
2296 } else if exact_count > 1 {
2297 return Err(ToolError::execution_failed(format!(
2298 "File `edit` search is non-unique: matched {} locations in {}. \
2299 Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
2300 exact_count,
2301 file_path.display()
2302 )));
2303 } else {
2304 let Some((start, end)) = first_exact_match else {
2305 return Err(ToolError::execution_failed(
2306 "edit_file internal range accounting failed — refusing write",
2307 ));
2308 };
2309 let fuzz_kind = (&contents[start..end] != search).then_some("line endings");
2310 ((start, end), fuzz_kind)
2311 };
2312
2313 let effective_replace =
2314 normalize_replacement_line_endings(replace, crlf_positions.is_some());
2315 let mut updated = contents.clone();
2316 updated.replace_range(match_start..match_end, &effective_replace);
2317 if updated == contents {
2318 return Err(ToolError::invalid_input(
2319 "search and replace resolve to identical file contents after line-ending normalization, no change intended",
2320 ));
2321 }
2322
2323 if let Some(reason) = invalid_preprocessor_edit(&file_path, &contents, &updated) {
2324 return Err(ToolError::invalid_input(format!(
2325 "edit_file refused corrupted payload: {reason}. Recovery: re-read the file and retry with a complete replace (or use apply_patch for brace-heavy multi-line edits)."
2326 )));
2327 }
2328
2329 // Fidelity: the intended replace text must appear in the updated buffer
2330 // (empty replace is a valid deletion). Catches host/tool bridges that
2331 // claim success after mangling the payload.
2332 if !effective_replace.is_empty() && !updated.contains(&effective_replace) {
2333 return Err(ToolError::execution_failed(
2334 "edit_file internal fidelity check failed: replace text missing from updated buffer — refusing write",
2335 ));
2336 }
2337
2338 guard_edit(&file_path, path_str, Some(&contents), &updated)?;
2339
2340 // #6205 — normalize after the syntax gate so the next turn's anchors
2341 // match the bytes on disk rather than the text the model emitted.
2342 let normalized_formatting = match normalize_edit(&file_path, &contents, &updated).await {
2343 Some(normalized) => {
2344 updated = normalized;
2345 true
2346 }
2347 None => false,
2348 };
2349
2350 run_blocking_write_atomic(&file_path, updated.clone().into_bytes()).await?;
2351
2352 // #5209 — never emit a success receipt unless the on-disk write
2353 // actually applied. A fabricated "Replaced 1 occurrence" + diff is
2354 // worse than a hard error: models trust it and re-edit the same
2355 // span 3–5× before noticing nothing changed.
2356 let on_disk = tokio::fs::read_to_string(&file_path).await.map_err(|e| {
2357 ToolError::execution_failed(format!(
2358 "Failed to verify write to {}: {}",
2359 file_path.display(),
2360 e
2361 ))
2362 })?;
2363 if on_disk != updated {
2364 return Err(ToolError::execution_failed(format!(
2365 "edit_file write verification failed for {}: on-disk contents do not match the applied edit — refusing success receipt",
2366 file_path.display()
2367 )));
2368 }
2369
2370 context.note_file_read(&file_path);
2371
2372 let display = file_path.display().to_string();
2373 let diff = make_unified_diff(&display, &contents, &updated);
2374 let fuzz_note = match fuzz_kind {
2375 Some("indentation") => " (fuzzy indentation match)",
2376 Some("punctuation") => {
2377 " (fuzzy punctuation match — typographic quotes/dashes normalized)"
2378 }
2379 Some("line endings") => " (CRLF/LF-normalized match)",
2380 Some(other) => other,
2381 None => "",
2382 };
2383 let format_note = if normalized_formatting {
2384 NORMALIZED_NOTE
2385 } else {
2386 ""
2387 };
2388 let summary = format!("Replaced 1 occurrence in {display}{fuzz_note}{format_note}");
2389 let body = if diff.is_empty() {
2390 format!("{summary}\n(no textual changes)")
2391 } else {
2392 format!("{diff}\n{summary}")
2393 };
2394
2395 // Append LSP diagnostics for the edited file when enabled (#428).
2396 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
2397 let full_body = if diag_block.is_empty() {
2398 body
2399 } else {
2400 format!("{body}\n{diag_block}")
2401 };
2402
2403 // The structured receipt uses the requested workspace path instead of
2404 // the resolved host path retained by the legacy model-facing body.
2405 let receipt_diff = make_unified_diff(path_str, &contents, &updated);
2406 Ok(ToolResult::success(full_body).with_metadata(json!({
2407 "event": "file.mutation",
2408 "mutation": {
2409 "diff": receipt_diff,
2410 "files": [{ "path": path_str, "outcome": "updated" }],
2411 "renames": []
2412 }
2413 })))
2414 }
2415 }
2416
2417 /// Detect catastrophic argument corruption of brace-structured edits.
2418 ///
2419 /// Models (and some host XML/JSON bridges) occasionally deliver a `replace`
2420 /// payload where a multi-line `{ ... }` block collapsed to empty `[]` or `{}`
2421 /// while `search` still contains the full structured original. Writing that
2422 /// would brick Rust match arms / JSON objects. Fail closed with recovery text
2423 /// instead of applying the mangled payload (dogfood 2026-07-24).
2424 ///
2425 /// Unbalanced-to-unbalanced edits with the **same** brace/bracket delta are
2426 /// legitimate (e.g. adding `});` inside a nested fragment). Only a *change*
2427 /// in balance is treated as truncation/mangling. Empty-bracket collapse and
2428 /// extreme-shrinkage guards remain.
2429 fn edit_payload_looks_corrupted(search: &str, replace: &str) -> Option<&'static str> {
2430 let search_curly_open = search.matches('{').count();
2431 let search_curly_close = search.matches('}').count();
2432 let replace_curly_open = replace.matches('{').count();
2433 let replace_curly_close = replace.matches('}').count();
2434 let search_square_open = search.matches('[').count();
2435 let search_square_close = search.matches(']').count();
2436 let replace_square_open = replace.matches('[').count();
2437 let replace_square_close = replace.matches(']').count();
2438
2439 let search_curly_delta = search_curly_open as i32 - search_curly_close as i32;
2440 let replace_curly_delta = replace_curly_open as i32 - replace_curly_close as i32;
2441 let search_square_delta = search_square_open as i32 - search_square_close as i32;
2442 let replace_square_delta = replace_square_open as i32 - replace_square_close as i32;
2443
2444 // Same delta on both sides (including both unbalanced the same way) is
2445 // normal for fragment edits. Divergent deltas usually mean truncation.
2446 if search_curly_delta != replace_curly_delta {
2447 return Some(
2448 "search/replace change `{`/`}` brace balance — the tool-call arguments were likely truncated or mangled before apply",
2449 );
2450 }
2451 if search_square_delta != replace_square_delta {
2452 return Some(
2453 "search/replace change `[`/`]` bracket balance — the tool-call arguments were likely truncated or mangled before apply",
2454 );
2455 }
2456
2457 // Dogfood 2026-07-24: multi-line Rust `{ ... }` search collapsed into an
2458 // empty `[ ... ]` placeholder (host/XML arg bridge ate the brace body).
2459 // Count non-whitespace, non-bracket payload chars; a near-empty bracket
2460 // husk with a tiny tail like `=> {},` is the signature of that failure.
2461 if search_curly_open >= 1 && replace_square_open >= 1 {
2462 let significant = replace
2463 .chars()
2464 .filter(|c| !c.is_whitespace() && *c != '[' && *c != ']')
2465 .count();
2466 if significant <= 12 {
2467 return Some(
2468 "replace collapsed a brace-structured search block into an empty/placeholder bracket span — refusing to brick the file; re-send the full replace text (prefer apply_patch for multi-line match arms)",
2469 );
2470 }
2471 }
2472
2473 // Extreme shrinkage with lost braces (e.g. 200-char match arm -> tiny stub).
2474 // Balanced-to-balanced nesting changes that shrink hard still look like
2475 // mangling; keep this guard even when deltas match.
2476 if search.len() >= 80
2477 && replace.len() * 8 < search.len()
2478 && search_curly_open >= 1
2479 && replace_curly_open < search_curly_open
2480 {
2481 return Some(
2482 "replace is drastically shorter than search and lost brace structure — likely argument mangling; refuse apply",
2483 );
2484 }
2485
2486 None
2487 }
2488
2489 const PREPROCESSOR_CONDITIONAL_ERROR: &str = "replace would change the C/C++ preprocessor conditional balance (#if/#ifdef/#ifndef vs #endif) — the search or replace text is missing a matching directive; copy the complete block including both its opening and closing directives";
2490
2491 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2492 struct PreprocessorConditionalDebt {
2493 orphaned_closes: usize,
2494 unclosed_opens: usize,
2495 }
2496
2497 impl PreprocessorConditionalDebt {
2498 fn total(self) -> usize {
2499 self.orphaned_closes + self.unclosed_opens
2500 }
2501 }
2502
2503 /// Reject an edit only when it introduces new conditional-structure damage in
2504 /// a file whose extension identifies it as C-family source. The whole file is
2505 /// checked before and after the edit: complete block insertion/removal is safe,
2506 /// while an orphaned opener or closer increases the structural debt. Existing
2507 /// debt may be preserved or reduced so this guard never prevents a repair.
2508 fn invalid_preprocessor_edit(path: &Path, before: &str, after: &str) -> Option<&'static str> {
2509 if !is_c_family_source(path) {
2510 return None;
2511 }
2512
2513 let before_debt = preprocessor_conditional_debt(before);
2514 let after_debt = preprocessor_conditional_debt(after);
2515 let safe = after_debt == before_debt
2516 || after_debt.total() == 0
2517 || after_debt.total() < before_debt.total();
2518
2519 (!safe).then_some(PREPROCESSOR_CONDITIONAL_ERROR)
2520 }
2521
2522 fn is_c_family_source(path: &Path) -> bool {
2523 const EXTENSIONS: &[&str] = &[
2524 "c", "cc", "cp", "cpp", "cxx", "h", "h++", "hh", "hpp", "hxx", "inl", "ipp", "ixx", "m",
2525 "mm", "tpp", "cu", "cuh", "cppm",
2526 ];
2527
2528 path.extension()
2529 .and_then(|extension| extension.to_str())
2530 .is_some_and(|extension| {
2531 EXTENSIONS
2532 .iter()
2533 .any(|candidate| extension.eq_ignore_ascii_case(candidate))
2534 })
2535 }
2536
2537 /// Measure unmatched preprocessor conditionals across an entire source file.
2538 /// Tracking nesting (instead of comparing span-level tuple counts) also catches
2539 /// an `#endif` moved before its opener. Whitespace between `#` and the directive
2540 /// name is accepted, as it is by C preprocessors.
2541 fn preprocessor_conditional_debt(text: &str) -> PreprocessorConditionalDebt {
2542 let mut depth = 0usize;
2543 let mut orphaned_closes = 0usize;
2544
2545 for line in text.lines() {
2546 match preprocessor_directive(line) {
2547 Some("if" | "ifdef" | "ifndef") => depth += 1,
2548 Some("endif") if depth == 0 => orphaned_closes += 1,
2549 Some("endif") => depth -= 1,
2550 _ => {}
2551 }
2552 }
2553
2554 PreprocessorConditionalDebt {
2555 orphaned_closes,
2556 unclosed_opens: depth,
2557 }
2558 }
2559
2560 fn preprocessor_directive(line: &str) -> Option<&str> {
2561 let rest = line.trim_start().strip_prefix('#')?.trim_start();
2562 let name_end = rest
2563 .find(|character: char| !character.is_ascii_alphabetic())
2564 .unwrap_or(rest.len());
2565 (name_end > 0).then_some(&rest[..name_end])
2566 }
2567
2568 /// Build a short, line-truncated preview of a (possibly very long) search
2569 /// payload for error messages, so the model can compare what it searched for
2570 /// against the file's actual contents without the error message ballooning.
2571 fn preview_search_for_error(search: &str) -> String {
2572 const MAX_PREVIEW_LINES: usize = 3;
2573 const MAX_PREVIEW_LINE_LEN: usize = 80;
2574 search
2575 .lines()
2576 .take(MAX_PREVIEW_LINES)
2577 .map(|line| {
2578 if line.chars().count() > MAX_PREVIEW_LINE_LEN {
2579 let mut truncated: String = line.chars().take(MAX_PREVIEW_LINE_LEN).collect();
2580 truncated.push_str("...");
2581 truncated
2582 } else {
2583 line.to_string()
2584 }
2585 })
2586 .collect::<Vec<_>>()
2587 .join("\n")
2588 }
2589
2590 /// Normalize Windows CRLF pairs to LF while retaining the normalized byte
2591 /// positions where a `\r` was removed. Lone carriage returns are preserved.
2592 /// Inputs without CRLF are borrowed and use identity offsets.
2593 ///
2594 /// A normalized boundary maps back to the original by adding the number of
2595 /// removed CR bytes strictly before it. At the normalized newline itself that
2596 /// excludes the current CR, so the start maps to `\r`; after the newline (or
2597 /// at EOF) it includes that CR and spans the full pair.
2598 fn normalize_crlf(input: &str) -> Cow<'_, str> {
2599 if input.contains("\r\n") {
2600 Cow::Owned(input.replace("\r\n", "\n"))
2601 } else {
2602 Cow::Borrowed(input)
2603 }
2604 }
2605
2606 fn normalize_crlf_with_positions(input: &str) -> (Cow<'_, str>, Option<Vec<usize>>) {
2607 if !input.contains("\r\n") {
2608 return (Cow::Borrowed(input), None);
2609 }
2610
2611 let mut normalized = String::with_capacity(input.len());
2612 let mut crlf_positions = Vec::new();
2613 let mut chars = input.char_indices().peekable();
2614
2615 while let Some((_, ch)) = chars.next() {
2616 if ch == '\r' && matches!(chars.peek(), Some((_, '\n'))) {
2617 let _ = chars.next();
2618 crlf_positions.push(normalized.len());
2619 normalized.push('\n');
2620 continue;
2621 }
2622
2623 normalized.push(ch);
2624 }
2625
2626 (Cow::Owned(normalized), Some(crlf_positions))
2627 }
2628
2629 fn map_normalized_range(
2630 (start, end): (usize, usize),
2631 crlf_positions: Option<&[usize]>,
2632 ) -> (usize, usize) {
2633 let Some(crlf_positions) = crlf_positions else {
2634 return (start, end);
2635 };
2636 let map_boundary =
2637 |offset| offset + crlf_positions.partition_point(|position| *position < offset);
2638 (map_boundary(start), map_boundary(end))
2639 }
2640
2641 fn map_normalized_ranges(
2642 ranges: impl IntoIterator<Item = (usize, usize)>,
2643 crlf_positions: Option<&[usize]>,
2644 ) -> Vec<(usize, usize)> {
2645 ranges
2646 .into_iter()
2647 .map(|range| map_normalized_range(range, crlf_positions))
2648 .collect()
2649 }
2650
2651 /// Convert model-provided replacement newlines to the base file's convention.
2652 /// Fold CRLF first so an already-CRLF payload never becomes `\r\r\n`.
2653 fn normalize_replacement_line_endings(replace: &str, use_crlf: bool) -> String {
2654 let lf = replace.replace("\r\n", "\n");
2655 if use_crlf {
2656 lf.replace('\n', "\r\n")
2657 } else {
2658 lf
2659 }
2660 }
2661
2662 fn strip_line_leading_whitespace_with_map(input: &str) -> (String, Vec<usize>) {
2663 let mut normalized = String::with_capacity(input.len());
2664 let mut byte_map = Vec::with_capacity(input.len());
2665 let mut at_line_start = true;
2666 for (idx, ch) in input.char_indices() {
2667 if at_line_start && matches!(ch, ' ' | '\t') {
2668 continue;
2669 }
2670 normalized.push(ch);
2671 for _ in 0..ch.len_utf8() {
2672 byte_map.push(idx);
2673 }
2674 at_line_start = ch == '\n';
2675 }
2676 (normalized, byte_map)
2677 }
2678
2679 fn line_start_before(input: &str, idx: usize) -> usize {
2680 input[..idx]
2681 .rfind('\n')
2682 .map_or(0, |newline| newline.saturating_add(1))
2683 }
2684
2685 fn next_char_boundary(input: &str, idx: usize) -> usize {
2686 if idx >= input.len() {
2687 return input.len();
2688 }
2689
2690 let mut next = idx.saturating_add(1);
2691 while next < input.len() && !input.is_char_boundary(next) {
2692 next = next.saturating_add(1);
2693 }
2694 next
2695 }
2696
2697 fn leading_whitespace_fuzzy_matches(contents: &str, search: &str) -> Vec<(usize, usize)> {
2698 let (normalized_contents, byte_map) = strip_line_leading_whitespace_with_map(contents);
2699 let (normalized_search, _) = strip_line_leading_whitespace_with_map(search);
2700 if normalized_search.is_empty() {
2701 return Vec::new();
2702 }
2703
2704 let mut matches = Vec::new();
2705 let mut cursor = 0;
2706 while let Some(rel_idx) = normalized_contents[cursor..].find(&normalized_search) {
2707 let norm_start = cursor + rel_idx;
2708 let norm_end = norm_start + normalized_search.len();
2709 let Some(&mapped_start) = byte_map.get(norm_start) else {
2710 break;
2711 };
2712 // Use the actual match start position, expanding to line start only
2713 // when the match begins at a line boundary in the normalized text.
2714 // This prevents destroying preceding text on the same line when
2715 // the match starts mid-line after whitespace stripping.
2716 let original_start =
2717 if norm_start == 0 || normalized_contents.as_bytes()[norm_start - 1] == b'\n' {
2718 // Match starts at a line boundary — use line start for full-line replacement.
2719 line_start_before(contents, mapped_start)
2720 } else {
2721 // Match starts mid-line — use the exact mapped position.
2722 mapped_start
2723 };
2724 let original_end = byte_map.get(norm_end).copied().unwrap_or(contents.len());
2725 matches.push((original_start, original_end));
2726 cursor = next_char_boundary(&normalized_contents, norm_start);
2727 }
2728 matches
2729 }
2730
2731 /// Normalize typographic punctuation to its ASCII counterpart:
2732 ///
2733 /// * `"` `"` / U+201C U+201D → `"`
2734 /// * `'` `'` / U+2018 U+2019 → `'`
2735 /// * `–` `—` / U+2013 U+2014 → `-`
2736 /// * U+00A0 (non-breaking space) → ASCII space
2737 ///
2738 /// Returns the normalized string plus a byte-map sized to
2739 /// `normalized.len()` whose i-th entry is the original byte offset of
2740 /// the character that produced normalized byte i. Used to recover the
2741 /// original-byte range after finding a match in normalized space.
2742 fn punctuation_normalized_with_map(input: &str) -> (String, Vec<usize>) {
2743 let mut normalized = String::with_capacity(input.len());
2744 let mut byte_map = Vec::with_capacity(input.len());
2745 for (idx, ch) in input.char_indices() {
2746 let replacement: Option<char> = match ch {
2747 '\u{201C}' | '\u{201D}' => Some('"'),
2748 '\u{2018}' | '\u{2019}' => Some('\''),
2749 '\u{2013}' | '\u{2014}' => Some('-'),
2750 '\u{00A0}' => Some(' '),
2751 _ => None,
2752 };
2753 let written = replacement.unwrap_or(ch);
2754 normalized.push(written);
2755 for _ in 0..written.len_utf8() {
2756 byte_map.push(idx);
2757 }
2758 }
2759 (normalized, byte_map)
2760 }
2761
2762 /// Try to find `search` inside `contents` after normalizing typographic
2763 /// punctuation in both. Catches the copy-paste failure mode where a
2764 /// browser, word processor, or chat client silently converted ASCII
2765 /// quotes/dashes to their Unicode "pretty" forms.
2766 fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, usize)> {
2767 let (norm_contents, byte_map) = punctuation_normalized_with_map(contents);
2768 let (norm_search, _) = punctuation_normalized_with_map(search);
2769 if norm_search.is_empty() {
2770 return Vec::new();
2771 }
2772 // If normalization didn't change anything, the exact-match pass
2773 // already considered this case — skip to avoid double-reporting.
2774 if norm_contents == contents && norm_search == search {
2775 return Vec::new();
2776 }
2777
2778 let mut matches = Vec::new();
2779 let mut cursor = 0;
2780 while let Some(rel_idx) = norm_contents[cursor..].find(&norm_search) {
2781 let norm_start = cursor + rel_idx;
2782 let norm_end = norm_start + norm_search.len();
2783 let Some(&original_start) = byte_map.get(norm_start) else {
2784 break;
2785 };
2786 let original_end = byte_map.get(norm_end).copied().unwrap_or(contents.len());
2787 matches.push((original_start, original_end));
2788 cursor = next_char_boundary(&norm_contents, norm_start);
2789 }
2790 matches
2791 }
2792
2793 // === ListDirTool ===
2794
2795 /// Tool for listing directory contents.
2796 pub struct ListDirTool;
2797
2798 const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30);
2799
2800 /// Cap on entries returned by a single `list_dir` call so a huge directory
2801 /// (node_modules, build output, photo dumps) can't balloon the tool result.
2802 /// Mirrors the bounded-output idiom of `read_file`'s `HARD_MAX_READ_LINES`.
2803 /// Directories at or under the cap keep the historical plain-array response;
2804 /// larger ones return an object with truncation metadata.
2805 const LIST_DIR_MAX_ENTRIES: usize = 500;
2806
2807 #[async_trait]
2808 impl ToolSpec for ListDirTool {
2809 fn name(&self) -> &'static str {
2810 "list_dir"
2811 }
2812
2813 fn model_visible(&self) -> bool {
2814 true
2815 }
2816
2817 fn description(&self) -> &'static str {
2818 "List entries in a workspace directory. This bounded, sandbox-aware tool is searchable when the core read/write/edit/bash toolbox is not enough."
2819 }
2820
2821 fn input_schema(&self) -> Value {
2822 json!({
2823 "type": "object",
2824 "properties": {
2825 "path": {
2826 "type": "string",
2827 "description": "Path to inspect (relative to workspace, absolute, or ~/ home-relative; default: .)"
2828 }
2829 },
2830 "required": []
2831 })
2832 }
2833
2834 fn capabilities(&self) -> Vec<ToolCapability> {
2835 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
2836 }
2837
2838 fn supports_parallel(&self) -> bool {
2839 true
2840 }
2841
2842 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
2843 let mut input = input;
2844 apply_param_aliases(&mut input, PATH_ALIASES, "File list")?;
2845 LIST_PARAMS.reject_unknown(&input)?;
2846
2847 let path_str = optional_str(&input, "path")?.unwrap_or(".");
2848 // S1: enumerating a denied directory is a read of it — `list_dir ~/.ssh`
2849 // hands back the key file names. Seatbelt's `deny file-read*` blocks
2850 // readdir of denied dirs, so refusing here matches the OS layer. The
2851 // raw spelling is checked first (F2) so the refusal names the caller's
2852 // path, never a symlink target it might resolve to.
2853 enforce_read_denylist(Path::new(path_str), "list_dir")?;
2854 let dir_path = context.resolve_path(path_str)?;
2855 enforce_read_denylist(&dir_path, "list_dir")?;
2856
2857 let entries =
2858 list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT)
2859 .await?;
2860
2861 ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
2862 }
2863 }
2864
2865 async fn list_dir_entries_async(
2866 dir_path: PathBuf,
2867 cancel_token: Option<CancellationToken>,
2868 timeout: Duration,
2869 ) -> Result<Value, ToolError> {
2870 let worker_cancel_token = cancel_token.clone();
2871 run_blocking_list_dir(timeout, cancel_token, move || {
2872 list_dir_entries(&dir_path, worker_cancel_token.as_ref())
2873 })
2874 .await
2875 }
2876
2877 async fn run_blocking_list_dir<F>(
2878 timeout: Duration,
2879 cancel_token: Option<CancellationToken>,
2880 list_dir: F,
2881 ) -> Result<Value, ToolError>
2882 where
2883 F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
2884 {
2885 if cancel_token
2886 .as_ref()
2887 .is_some_and(CancellationToken::is_cancelled)
2888 {
2889 return Err(list_dir_cancelled());
2890 }
2891
2892 let task = tokio::task::spawn_blocking(list_dir);
2893 let result = match cancel_token {
2894 Some(token) => {
2895 tokio::select! {
2896 biased;
2897 () = token.cancelled() => return Err(list_dir_cancelled()),
2898 result = tokio::time::timeout(timeout, task) => result,
2899 }
2900 }
2901 None => tokio::time::timeout(timeout, task).await,
2902 };
2903
2904 let joined = result.map_err(|_| list_dir_timeout(timeout))?;
2905 joined.map_err(|err| {
2906 ToolError::execution_failed(format!("list_dir worker failed before completion: {err}"))
2907 })?
2908 }
2909
2910 fn list_dir_entries(
2911 dir_path: &Path,
2912 cancel_token: Option<&CancellationToken>,
2913 ) -> Result<Value, ToolError> {
2914 check_list_dir_cancelled(cancel_token)?;
2915
2916 let mut entries = Vec::new();
2917 let mut total_entries = 0usize;
2918
2919 for entry in fs::read_dir(dir_path).map_err(|e| {
2920 ToolError::execution_failed(format!(
2921 "Failed to read directory {}: {}",
2922 dir_path.display(),
2923 e
2924 ))
2925 })? {
2926 check_list_dir_cancelled(cancel_token)?;
2927
2928 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
2929 total_entries += 1;
2930 // Past the cap, keep counting for the truncation metadata but stop
2931 // materializing entries.
2932 if entries.len() >= LIST_DIR_MAX_ENTRIES {
2933 continue;
2934 }
2935 let file_type = entry
2936 .file_type()
2937 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
2938
2939 entries.push(json!({
2940 "name": entry.file_name().to_string_lossy().to_string(),
2941 "is_dir": file_type.is_dir(),
2942 }));
2943 }
2944
2945 if total_entries > entries.len() {
2946 Ok(json!({
2947 "entries": entries,
2948 "listed_entries": LIST_DIR_MAX_ENTRIES,
2949 "total_entries": total_entries,
2950 "truncated": true,
2951 }))
2952 } else {
2953 Ok(Value::Array(entries))
2954 }
2955 }
2956
2957 fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
2958 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
2959 return Err(list_dir_cancelled());
2960 }
2961 Ok(())
2962 }
2963
2964 fn list_dir_cancelled() -> ToolError {
2965 ToolError::cancelled("list_dir cancelled before completion")
2966 }
2967
2968 fn list_dir_timeout(timeout: Duration) -> ToolError {
2969 ToolError::Timeout {
2970 seconds: timeout.as_secs().max(1),
2971 }
2972 }
2973
2974 // === Unit Tests ===
2975
2976 #[cfg(test)]
2977 #[path = "file/tests.rs"]
2978 mod pdf_tests;
2979
2980 #[cfg(test)]
2981 #[path = "file/tests/tools.rs"]
2982 mod tests;
2983
2983 lines RUST