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#[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 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 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 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}