Skip to main content

winx_code_agent/tools/edit_files/
domain.rs

1use std::collections::HashSet;
2use std::path::{Component, Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8use crate::errors::{Result, WinxError};
9use crate::state::bash_state::BashState;
10use crate::tool_policy::EditPermissionSet;
11use crate::tool_registry::ToolKind;
12use crate::tools::file_write_or_edit::resolve_edit_path;
13use crate::types::{normalize_thread_id, LinePatch};
14
15const MAX_FILES: usize = 100;
16const MAX_TOTAL_PATCHES: usize = 1_024;
17const MAX_AGGREGATE_CONTENT_BYTES: usize = 64 * 1024 * 1024;
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum EditOperation {
22    Apply,
23    Undo,
24}
25
26#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum EditMode {
29    Replace,
30    SearchReplace,
31    LinePatch,
32    Undo,
33}
34
35#[derive(Clone, Debug, PartialEq)]
36pub enum EditChange {
37    Replace { file_path: String, content: String },
38    SearchReplace { file_path: String, content: String },
39    LinePatch { file_path: String, expected_revision: String, patches: Vec<LinePatch> },
40}
41
42impl EditChange {
43    pub fn file_path(&self) -> &str {
44        match self {
45            Self::Replace { file_path, .. }
46            | Self::SearchReplace { file_path, .. }
47            | Self::LinePatch { file_path, .. } => file_path,
48        }
49    }
50
51    pub const fn mode(&self) -> EditMode {
52        match self {
53            Self::Replace { .. } => EditMode::Replace,
54            Self::SearchReplace { .. } => EditMode::SearchReplace,
55            Self::LinePatch { .. } => EditMode::LinePatch,
56        }
57    }
58
59    fn canonical_value(&self, resolved_path: &str) -> Value {
60        match self {
61            Self::Replace { content, .. } => {
62                json!({"file_path": resolved_path, "mode": "replace", "content": content})
63            }
64            Self::SearchReplace { content, .. } => json!({
65                "file_path": resolved_path,
66                "mode": "search_replace",
67                "content": content
68            }),
69            Self::LinePatch { expected_revision, patches, .. } => json!({
70                "file_path": resolved_path,
71                "mode": "line_patch",
72                "expected_revision": expected_revision,
73                "patches": patches
74            }),
75        }
76    }
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub enum EditCommand {
81    Apply { changes: Vec<EditChange> },
82    Undo { file_path: String, undo_id: Option<String>, legacy_lifo: bool },
83}
84
85impl EditCommand {
86    pub const fn operation(&self) -> EditOperation {
87        match self {
88            Self::Apply { .. } => EditOperation::Apply,
89            Self::Undo { .. } => EditOperation::Undo,
90        }
91    }
92
93    pub fn modes(&self) -> Vec<EditMode> {
94        match self {
95            Self::Apply { changes } => changes.iter().map(EditChange::mode).collect(),
96            Self::Undo { .. } => vec![EditMode::Undo],
97        }
98    }
99
100    pub fn file_paths(&self) -> Vec<&str> {
101        match self {
102            Self::Apply { changes } => changes.iter().map(EditChange::file_path).collect(),
103            Self::Undo { file_path, .. } => vec![file_path],
104        }
105    }
106
107    pub fn file_count(&self) -> usize {
108        self.file_paths().len()
109    }
110}
111
112#[derive(Clone, Debug)]
113pub struct EditVerification {
114    pub command: String,
115    pub wait_for_seconds: Option<f32>,
116}
117
118/// Final filesystem identity captured during edit preflight. Once constructed,
119/// downstream planning and commit code must use this path directly and must not
120/// resolve the caller's raw spelling (or the session cwd) again.
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub(crate) struct CanonicalEditTarget {
123    path: PathBuf,
124    identity: String,
125    existed_at_preflight: bool,
126}
127
128impl CanonicalEditTarget {
129    pub(crate) fn from_preflight(path: PathBuf) -> Result<Self> {
130        if !path.is_absolute()
131            || path
132                .components()
133                .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
134        {
135            return Err(WinxError::InvalidInput(
136                "edit preflight produced a non-canonical target".to_string(),
137            ));
138        }
139        let identity = path.to_string_lossy().into_owned();
140        let existed_at_preflight = std::fs::symlink_metadata(&path).is_ok();
141        Ok(Self { path, identity, existed_at_preflight })
142    }
143
144    pub(crate) fn path(&self) -> &Path {
145        &self.path
146    }
147
148    pub(crate) fn identity(&self) -> &str {
149        &self.identity
150    }
151
152    pub(crate) fn validate_binding(&self) -> Result<()> {
153        let exists_now = std::fs::symlink_metadata(&self.path).is_ok();
154        if exists_now != self.existed_at_preflight {
155            return Err(self.binding_error("target existence changed after preflight"));
156        }
157        let current = if exists_now {
158            self.path.canonicalize().map(crate::utils::path::simplified).map_err(|error| {
159                self.binding_error(&format!("cannot revalidate target: {error}"))
160            })?
161        } else {
162            resolve_missing_target(&self.path).map_err(|error| {
163                self.binding_error(&format!("cannot revalidate target: {error}"))
164            })?
165        };
166        if current != self.path {
167            return Err(self.binding_error("a symlink or parent directory was retargeted"));
168        }
169        Ok(())
170    }
171
172    fn binding_error(&self, message: &str) -> WinxError {
173        WinxError::FileChangedAfterEdit {
174            path: self.path.clone(),
175            message: format!(
176                "canonical edit target changed after preflight ({message}); no write was attempted"
177            ),
178        }
179    }
180}
181
182fn resolve_missing_target(path: &Path) -> std::io::Result<PathBuf> {
183    let mut existing = path;
184    while std::fs::symlink_metadata(existing).is_err() {
185        existing = existing.parent().ok_or_else(|| {
186            std::io::Error::new(std::io::ErrorKind::NotFound, "no existing target ancestor")
187        })?;
188    }
189    let mut resolved = existing.canonicalize().map(crate::utils::path::simplified)?;
190    let remainder = path.strip_prefix(existing).map_err(|_| {
191        std::io::Error::new(
192            std::io::ErrorKind::InvalidInput,
193            "target escaped its captured ancestor",
194        )
195    })?;
196    for component in remainder.components() {
197        match component {
198            Component::Normal(component) => resolved.push(component),
199            Component::CurDir => {}
200            _ => {
201                return Err(std::io::Error::new(
202                    std::io::ErrorKind::InvalidInput,
203                    "non-canonical target remainder",
204                ));
205            }
206        }
207    }
208    Ok(resolved)
209}
210
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub enum EditSurface {
213    FileWriteOrEdit,
214    MultiFileEdit,
215    ApplyPatch,
216    UndoEdit,
217    EditFiles,
218}
219
220impl EditSurface {
221    pub const fn legacy_tool_kind(self) -> Option<ToolKind> {
222        match self {
223            Self::FileWriteOrEdit => Some(ToolKind::FileWriteOrEdit),
224            Self::MultiFileEdit => Some(ToolKind::MultiFileEdit),
225            Self::ApplyPatch => Some(ToolKind::ApplyPatch),
226            Self::UndoEdit => Some(ToolKind::UndoEdit),
227            Self::EditFiles => None,
228        }
229    }
230
231    pub const fn tool_name(self) -> &'static str {
232        match self {
233            Self::FileWriteOrEdit => "FileWriteOrEdit",
234            Self::MultiFileEdit => "MultiFileEdit",
235            Self::ApplyPatch => "ApplyPatch",
236            Self::UndoEdit => "UndoEdit",
237            Self::EditFiles => "EditFiles",
238        }
239    }
240
241    pub const fn is_legacy(self) -> bool {
242        !matches!(self, Self::EditFiles)
243    }
244
245    pub const fn from_public_tool(tool: ToolKind) -> Option<Self> {
246        match tool {
247            ToolKind::FileWriteOrEdit => Some(Self::FileWriteOrEdit),
248            ToolKind::MultiFileEdit => Some(Self::MultiFileEdit),
249            ToolKind::ApplyPatch => Some(Self::ApplyPatch),
250            ToolKind::UndoEdit => Some(Self::UndoEdit),
251            ToolKind::EditFiles => Some(Self::EditFiles),
252            _ => None,
253        }
254    }
255
256    pub(crate) const fn source_permissions(self) -> EditPermissionSet {
257        match self.legacy_tool_kind() {
258            Some(tool) => EditPermissionSet::for_legacy_tool(tool),
259            None => EditPermissionSet::all_mutations(),
260        }
261    }
262}
263
264#[derive(Clone, Debug)]
265pub struct NormalizedEditCall {
266    pub surface: EditSurface,
267    pub command: EditCommand,
268    pub verification: Option<EditVerification>,
269    pub thread_id: String,
270    pub workspace_root: Option<String>,
271    pub original_arguments: Value,
272}
273
274#[derive(Clone, Debug)]
275pub struct PreparedEditContext {
276    pub surface: EditSurface,
277    pub command: EditCommand,
278    pub verification: Option<EditVerification>,
279    pub thread_id: String,
280    pub workspace_root: Option<String>,
281    /// Canonical project identity captured from the initialized session. Raw
282    /// caller spelling is retained only for compatibility responses.
283    pub canonical_workspace_root: String,
284    pub(crate) targets: Vec<CanonicalEditTarget>,
285    pub original_arguments: Value,
286    pub(crate) effective_permissions: EditPermissionSet,
287}
288
289impl PreparedEditContext {
290    pub fn prepare(
291        normalized: NormalizedEditCall,
292        state: &BashState,
293        effective_permissions: EditPermissionSet,
294    ) -> Result<Self> {
295        let thread_id = normalize_thread_id(&normalized.thread_id);
296        if thread_id != state.current_thread_id {
297            return Err(WinxError::ThreadIdMismatch(thread_id));
298        }
299
300        let file_count = normalized.command.file_count();
301        if file_count == 0 || file_count > MAX_FILES {
302            return Err(WinxError::InvalidInput(format!(
303                "EditFiles files must contain between 1 and {MAX_FILES} entries"
304            )));
305        }
306        if matches!(normalized.command, EditCommand::Undo { .. }) && file_count != 1 {
307            return Err(WinxError::InvalidInput(
308                "EditFiles undo requires exactly one file".to_string(),
309            ));
310        }
311        if normalized.verification.is_some() && !effective_permissions.allows_verification() {
312            return Err(WinxError::CommandNotAllowed(
313                "edit verification requires BashCommand authority in the effective tool policy"
314                    .to_string(),
315            ));
316        }
317
318        let source_permissions = normalized.surface.source_permissions();
319        let mut targets = Vec::with_capacity(file_count);
320        let mut seen = HashSet::with_capacity(file_count);
321        for (mode, requested) in
322            normalized.command.modes().into_iter().zip(normalized.command.file_paths())
323        {
324            if !effective_permissions.allows(mode, file_count)
325                || !source_permissions.allows(mode, file_count)
326            {
327                return Err(WinxError::FileOperationDenied {
328                    path: requested.into(),
329                    message: format!(
330                        "{} does not authorize mode={} with {} file(s)",
331                        normalized.surface.tool_name(),
332                        mode.as_str(),
333                        file_count
334                    ),
335                });
336            }
337            let (_, resolved) = resolve_edit_path(state, requested)?;
338            let target = CanonicalEditTarget::from_preflight(resolved)?;
339            let resolved_string = target.identity().to_string();
340            let mode_allowed = match mode {
341                EditMode::Replace => state.is_file_write_allowed(&resolved_string),
342                EditMode::SearchReplace | EditMode::LinePatch | EditMode::Undo => {
343                    state.is_file_edit_allowed(&resolved_string)
344                }
345            };
346            if !mode_allowed {
347                return Err(WinxError::FileOperationDenied {
348                    path: target.path().to_path_buf(),
349                    message: format!(
350                        "{} is not allowed for this path in the current shell mode",
351                        mode.as_str()
352                    ),
353                });
354            }
355            if !seen.insert(resolved_string.clone()) {
356                return Err(if normalized.surface == EditSurface::MultiFileEdit {
357                    WinxError::ArgumentParseError(format!(
358                        "MultiFileEdit targets '{resolved_string}' more than once; edits to the same file don't compose - combine them into a single entry."
359                    ))
360                } else {
361                    WinxError::InvalidInput(format!(
362                        "EditFiles targets {resolved_string:?} more than once; combine edits for one file"
363                    ))
364                });
365            }
366            targets.push(target);
367        }
368
369        Ok(Self {
370            surface: normalized.surface,
371            command: normalized.command,
372            verification: normalized.verification,
373            thread_id,
374            workspace_root: normalized.workspace_root,
375            canonical_workspace_root: state.workspace_root.to_string_lossy().into_owned(),
376            targets,
377            original_arguments: normalized.original_arguments,
378            effective_permissions,
379        })
380    }
381
382    /// Recheck every authorization decision while the session state is locked.
383    /// This closes the gap between preflight and persisted-receipt lookup when a
384    /// concurrent mode change tightens file authority.
385    pub(crate) fn authorize_current_state(&self, state: &BashState) -> Result<()> {
386        if self.thread_id != state.current_thread_id {
387            return Err(WinxError::ThreadIdMismatch(self.thread_id.clone()));
388        }
389        let file_count = self.targets.len();
390        for (mode, target) in self.command.modes().into_iter().zip(&self.targets) {
391            // Binding is revalidated by the planner immediately before I/O.
392            // Receipt replay deliberately checks only authority here: the
393            // committed mutation itself may have created a previously missing
394            // target, which must not invalidate an otherwise exact replay.
395            let path = target.identity();
396            if !self.effective_permissions.allows(mode, file_count) {
397                return Err(WinxError::FileOperationDenied {
398                    path: path.into(),
399                    message: format!(
400                        "effective tool policy no longer authorizes mode={} with {file_count} file(s)",
401                        mode.as_str()
402                    ),
403                });
404            }
405            let allowed = match mode {
406                EditMode::Replace => state.is_file_write_allowed(path),
407                EditMode::SearchReplace | EditMode::LinePatch | EditMode::Undo => {
408                    state.is_file_edit_allowed(path)
409                }
410            };
411            if !allowed {
412                return Err(WinxError::FileOperationDenied {
413                    path: path.into(),
414                    message: format!(
415                        "{} is no longer allowed for this path in the current shell mode",
416                        mode.as_str()
417                    ),
418                });
419            }
420        }
421        Ok(())
422    }
423
424    pub fn canonical_value(&self) -> Value {
425        let command = match &self.command {
426            EditCommand::Apply { changes } => json!({
427                "operation": "apply",
428                "files": changes
429                    .iter()
430                    .zip(&self.targets)
431                    .map(|(change, target)| change.canonical_value(target.identity()))
432                    .collect::<Vec<_>>()
433            }),
434            EditCommand::Undo { undo_id, .. } => json!({
435                "operation": "undo",
436                "files": [{
437                    "file_path": self.targets.first().map(CanonicalEditTarget::identity).unwrap_or_default(),
438                    "mode": "undo",
439                    "undo_id": undo_id
440                }]
441            }),
442        };
443        json!({
444            "schema": "EditFiles/v1",
445            "thread_id": self.thread_id,
446            "workspace_root": self.canonical_workspace_root,
447            "command": command
448        })
449    }
450
451    pub fn audit_summary(&self) -> String {
452        format!(
453            "operation={} files={} modes={} verify={}",
454            self.command.operation().as_str(),
455            self.targets.len(),
456            self.command.modes().iter().map(|mode| mode.as_str()).collect::<Vec<_>>().join(","),
457            self.verification.is_some()
458        )
459    }
460
461    pub(crate) fn targets(&self) -> &[CanonicalEditTarget] {
462        &self.targets
463    }
464
465    pub(crate) fn target_paths(&self) -> Vec<String> {
466        self.targets.iter().map(|target| target.identity().to_string()).collect()
467    }
468}
469
470impl EditOperation {
471    pub const fn as_str(self) -> &'static str {
472        match self {
473            Self::Apply => "apply",
474            Self::Undo => "undo",
475        }
476    }
477}
478
479impl EditMode {
480    pub const fn as_str(self) -> &'static str {
481        match self {
482            Self::Replace => "replace",
483            Self::SearchReplace => "search_replace",
484            Self::LinePatch => "line_patch",
485            Self::Undo => "undo",
486        }
487    }
488}
489
490pub(super) fn validate_aggregate(changes: &[EditChange]) -> Result<()> {
491    let total_patches = changes
492        .iter()
493        .map(|change| match change {
494            EditChange::LinePatch { patches, .. } => patches.len(),
495            _ => 0,
496        })
497        .sum::<usize>();
498    if total_patches > MAX_TOTAL_PATCHES {
499        return Err(WinxError::InvalidInput(format!(
500            "EditFiles is limited to {MAX_TOTAL_PATCHES} total line patches per call"
501        )));
502    }
503    let total_content = changes
504        .iter()
505        .map(|change| match change {
506            EditChange::Replace { content, .. } | EditChange::SearchReplace { content, .. } => {
507                content.len()
508            }
509            EditChange::LinePatch { patches, .. } => {
510                patches.iter().map(|patch| patch.replacement.len()).sum()
511            }
512        })
513        .sum::<usize>();
514    if total_content > MAX_AGGREGATE_CONTENT_BYTES {
515        return Err(WinxError::InvalidInput(format!(
516            "EditFiles payload content exceeds the {MAX_AGGREGATE_CONTENT_BYTES}-byte aggregate limit"
517        )));
518    }
519    Ok(())
520}