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