Skip to main content

winx_code_agent/utils/
agent_temp.rs

1//! Managed workspace-local temporary artifacts for model-driven helpers.
2//!
3//! `Initialize` advertises one stable directory per Winx session under
4//! `<workspace>/.winx/tmp/`. The directory is intentionally not created until a
5//! file tool actually needs it, so merely inspecting a repository does not
6//! dirty its working tree. File tools enforce a small path and storage budget;
7//! Bash remains governed by its own operator-selected command policy.
8
9pub mod release;
10
11use std::collections::BTreeSet;
12use std::ffi::OsStr;
13use std::fs;
14use std::path::{Component, Path, PathBuf};
15use std::sync::Mutex;
16use std::time::Duration;
17
18use serde::Serialize;
19use sha2::{Digest, Sha256};
20use tracing::info;
21
22use crate::errors::{Result, WinxError};
23
24/// `<workspace>/.winx/tmp`, composed per platform so Windows never mixes
25/// separators inside `temporary_artifact_dir`.
26fn temp_root() -> PathBuf {
27    Path::new(".winx").join("tmp")
28}
29const SESSION_PREFIX: &str = "session-";
30const SESSION_HASH_BYTES: usize = 8;
31
32/// Retained age-policy hint. Unguarded maintenance does not delete files: a
33/// capable runtime owner must establish eligibility for explicit release.
34pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
35/// Workspace-wide budget shared by all managed temporary sessions.
36pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
37/// One active conversation cannot consume the whole workspace-wide budget.
38pub const MAX_SESSION_BYTES: u64 = 64 * 1024 * 1024;
39/// One helper should remain small enough to inspect and remove cheaply.
40pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
41/// Hundreds of one-shot carriers are almost always an orchestration failure.
42pub const MAX_SESSION_FILES: usize = 128;
43/// Start reclaiming old active-session helpers before the hard file cap.
44pub const ACTIVE_SESSION_PRUNE_TRIGGER_FILES: usize = MAX_SESSION_FILES * 3 / 4;
45/// Stop file-count reclamation once the session has comfortable headroom.
46pub const ACTIVE_SESSION_PRUNE_TARGET_FILES: usize = MAX_SESSION_FILES / 2;
47/// Start reclaiming old active-session helpers before the hard byte cap.
48pub const ACTIVE_SESSION_PRUNE_TRIGGER_BYTES: u64 = MAX_SESSION_BYTES * 3 / 4;
49/// Stop byte reclamation once the session has comfortable headroom.
50pub const ACTIVE_SESSION_PRUNE_TARGET_BYTES: u64 = MAX_SESSION_BYTES / 2;
51/// Derived syntax maps are intentionally much smaller than canonical maps.
52pub const MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES: usize = 12 * 1024;
53/// A session should reuse a small working set instead of minting new carriers.
54pub const MAX_DERIVED_CODE_MAP_UNIQUE_FILES: usize = 24;
55/// Bound aggregate helper-map churn while leaving canonical `CodeMap` unlimited.
56pub const MAX_DERIVED_CODE_MAP_CALLS: usize = 64;
57/// Depth beneath the session directory, including the target filename.
58pub const MAX_RELATIVE_COMPONENTS: usize = 8;
59/// Prevent payloads from being smuggled into a filename or directory component.
60pub const MAX_COMPONENT_BYTES: usize = 120;
61/// Bound the whole session-relative path independently of component count.
62pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
63
64const MAX_SCAN_ENTRIES: usize = 50_000;
65const MAX_SCAN_DEPTH: usize = 16;
66static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
67
68/// Policy information returned by `Initialize` and reused by file-tool errors.
69#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct AgentTempInfo {
71    pub directory: PathBuf,
72    pub ttl_seconds: u64,
73    pub max_total_bytes: u64,
74    pub max_session_bytes: u64,
75    pub max_file_bytes: u64,
76    pub max_session_files: usize,
77    pub stale_pruned_files: usize,
78    pub stale_pruned_bytes: u64,
79}
80
81/// Bounded filesystem audit attached to Bash results. It contains counts and
82/// sizes only, never helper contents.
83#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
84pub struct TemporaryArtifactUsage {
85    pub temporary_artifact_dir: PathBuf,
86    pub total_bytes: u64,
87    pub max_total_bytes: u64,
88    pub session_bytes: u64,
89    pub max_session_bytes: u64,
90    pub session_files: usize,
91    pub max_session_files: usize,
92    pub largest_file_bytes: u64,
93    pub max_file_bytes: u64,
94    pub stale_pruned_files: usize,
95    pub stale_pruned_bytes: u64,
96    pub over_budget: bool,
97    pub deletion_deferred: &'static str,
98}
99
100impl TemporaryArtifactUsage {
101    pub fn requires_cleanup(&self) -> bool {
102        self.over_budget
103    }
104
105    fn as_budget_error(&self) -> WinxError {
106        WinxError::TemporaryArtifactBudgetExceeded {
107            temporary_artifact_dir: self.temporary_artifact_dir.clone(),
108            total_bytes: self.total_bytes,
109            max_total_bytes: self.max_total_bytes,
110            session_bytes: self.session_bytes,
111            max_session_bytes: self.max_session_bytes,
112            session_files: self.session_files,
113            max_session_files: self.max_session_files,
114            largest_file_bytes: self.largest_file_bytes,
115            max_file_bytes: self.max_file_bytes,
116        }
117    }
118}
119
120/// In-memory budget for syntax navigation over non-canonical helpers.
121///
122/// It deliberately resets with the server process. Filesystem quotas remain the
123/// durable backstop across restarts, while this keeps the hot session path free
124/// from another persistence write after every read-only `CodeMap` call.
125#[derive(Clone, Debug, Default)]
126pub struct DerivedCodeMapUsage {
127    mapped_files: BTreeSet<PathBuf>,
128    calls: usize,
129}
130
131/// Counters returned with an accepted derived-helper map.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub struct DerivedCodeMapPermit {
134    pub calls_used: usize,
135    pub calls_limit: usize,
136    pub unique_files_used: usize,
137    pub unique_files_limit: usize,
138}
139
140/// A resolved edit and its size change, used to validate a `MultiFileEdit`
141/// against the workspace-wide quota before any file is committed.
142#[derive(Clone, Copy, Debug)]
143pub struct TempEdit<'a> {
144    pub path: &'a Path,
145    pub previous_bytes: u64,
146    pub new_bytes: u64,
147    pub is_new: bool,
148}
149
150/// Compute the stable session directory without touching the filesystem.
151pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
152    let workspace = workspace_root
153        .canonicalize()
154        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
155    AgentTempInfo {
156        directory: workspace.join(temp_root()).join(session_name(thread_id)),
157        ttl_seconds: MAX_AGE.as_secs(),
158        max_total_bytes: MAX_TOTAL_BYTES,
159        max_session_bytes: MAX_SESSION_BYTES,
160        max_file_bytes: MAX_FILE_BYTES,
161        max_session_files: MAX_SESSION_FILES,
162        stale_pruned_files: 0,
163        stale_pruned_bytes: 0,
164    }
165}
166
167/// Return the session policy without deleting files whose runtime ownership
168/// cannot be established here. The active directory is not created here; file
169/// tools create it on demand through their normal parent-directory path.
170pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
171    let mut info = session_info(workspace_root, thread_id);
172    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
173    prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
174    let pruned = prune_active_session_unlocked(&info.directory, MAX_AGE);
175    info.stale_pruned_files = pruned.files;
176    info.stale_pruned_bytes = pruned.bytes;
177    log_active_prune(&info.directory, pruned);
178    info
179}
180
181/// Measure the active managed session after a Bash action. This closes the gap
182/// left by dynamic loops, scripts, and generated filenames that static command
183/// inspection cannot project before the shell runs.
184pub fn audit_session(workspace_root: &Path, thread_id: &str) -> Result<TemporaryArtifactUsage> {
185    audit_session_impl(workspace_root, thread_id, false)
186}
187
188/// Return a bounded audit while deferring automatic deletion. This utility has
189/// no authoritative runtime admission guard, including for sibling sessions.
190pub fn maintain_and_audit_session(
191    workspace_root: &Path,
192    thread_id: &str,
193) -> Result<TemporaryArtifactUsage> {
194    audit_session_impl(workspace_root, thread_id, true)
195}
196
197fn audit_session_impl(
198    workspace_root: &Path,
199    thread_id: &str,
200    maintain: bool,
201) -> Result<TemporaryArtifactUsage> {
202    let info = session_info(workspace_root, thread_id);
203    let workspace = workspace_root
204        .canonicalize()
205        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
206    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
207    let pruned = if maintain {
208        prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
209        prune_active_session_unlocked(&info.directory, MAX_AGE)
210    } else {
211        PrunedArtifacts::default()
212    };
213    log_active_prune(&info.directory, pruned);
214    let usage =
215        temp_tree_usage(&workspace.join(temp_root()), &info.directory).ok_or_else(|| {
216            policy_error(
217                &info.directory,
218                &info,
219                "temporary storage could not be measured safely; remove malformed or excessive \
220             artifacts before retrying"
221                    .to_string(),
222            )
223        })?;
224    let over_budget = usage.total_bytes > MAX_TOTAL_BYTES
225        || usage.session_bytes > MAX_SESSION_BYTES
226        || usage.session_files > MAX_SESSION_FILES
227        || usage.largest_file_bytes > MAX_FILE_BYTES;
228    Ok(TemporaryArtifactUsage {
229        temporary_artifact_dir: info.directory,
230        total_bytes: usage.total_bytes,
231        max_total_bytes: MAX_TOTAL_BYTES,
232        session_bytes: usage.session_bytes,
233        max_session_bytes: MAX_SESSION_BYTES,
234        session_files: usage.session_files,
235        max_session_files: MAX_SESSION_FILES,
236        largest_file_bytes: usage.largest_file_bytes,
237        max_file_bytes: MAX_FILE_BYTES,
238        stale_pruned_files: pruned.files,
239        stale_pruned_bytes: pruned.bytes,
240        over_budget,
241        deletion_deferred: "runtime_protection_unknown",
242    })
243}
244
245/// Reserve guardian recovery objects from every supported file mutation.
246pub fn validate_mutation_namespace(
247    workspace_root: &Path,
248    thread_id: &str,
249    requested_path: &Path,
250    resolved_path: &Path,
251) -> Result<()> {
252    let info = session_info(workspace_root, thread_id);
253    let workspace = workspace_root
254        .canonicalize()
255        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
256    for path in [requested_path, resolved_path] {
257        if workspace_relative(path, workspace_root, &workspace).as_deref().is_some_and(|relative| {
258            is_temp_relative(relative)
259                && relative.components().any(|component| {
260                    component.as_os_str().as_encoded_bytes().starts_with(b".winx-release-")
261                })
262        }) {
263            return Err(policy_error(
264                path,
265                &info,
266                "release quarantine is reserved for recovery; file mutations are denied".into(),
267            ));
268        }
269    }
270    Ok(())
271}
272
273/// Validate one `FileWriteOrEdit` target. Non-temporary project files keep their
274/// existing behavior; only legacy Winx root artifacts and `.winx/tmp` receive
275/// this additional governance.
276pub fn validate_edit_target(
277    workspace_root: &Path,
278    thread_id: &str,
279    requested_path: &Path,
280    resolved_path: &Path,
281    previous_bytes: Option<u64>,
282    new_bytes: u64,
283) -> Result<()> {
284    validate_mutation_namespace(workspace_root, thread_id, requested_path, resolved_path)?;
285    let info = session_info(workspace_root, thread_id);
286    let workspace = workspace_root
287        .canonicalize()
288        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
289    // On macOS, tempfile and callers may spell the same workspace as `/var/...`
290    // while canonicalization produces `/private/var/...`. Accept either spelling
291    // at this boundary, then rebase policy checks onto the canonical workspace.
292    // The resolved path from the file-safety layer is normally canonical already;
293    // keeping the lexical fallback makes this helper safe and independently
294    // testable without allowing an alias to bypass temporary-artifact policy.
295    let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
296    let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
297
298    if let Some(relative) = resolved_relative.as_deref() {
299        reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
300    }
301    if requested_relative != resolved_relative {
302        if let Some(relative) = requested_relative.as_deref() {
303            reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
304        }
305    }
306
307    let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
308    let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
309    if !requested_temp && !resolved_temp {
310        return Ok(());
311    }
312
313    reject_symlinked_temp_root(&workspace, requested_path, &info)?;
314    let policy_path = resolved_relative
315        .as_deref()
316        .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
317    validate_session_relative_path(&policy_path, &info)?;
318    if new_bytes > MAX_FILE_BYTES {
319        return Err(policy_error(
320            &policy_path,
321            &info,
322            format!(
323                "helper content is {new_bytes} bytes; one temporary file is limited to \
324                 {MAX_FILE_BYTES} bytes"
325            ),
326        ));
327    }
328
329    Ok(())
330}
331
332/// Reject statically visible shell writes that bypass the managed session area.
333///
334/// Bash remains a general-purpose operator-controlled capability, so this is not
335/// presented as a filesystem sandbox. It covers the high-confidence cases an
336/// agent can correct immediately: literal output redirects, common destination
337/// arguments, and embedded language file-writer calls that name a Winx helper.
338pub fn validate_bash_command(
339    workspace_root: &Path,
340    cwd: &Path,
341    thread_id: &str,
342    command: &str,
343) -> Result<()> {
344    let info = session_info(workspace_root, thread_id);
345    let recovery_command = is_explicit_temp_recovery(command, &info);
346    let usage = match maintain_and_audit_session(workspace_root, thread_id) {
347        Ok(usage) => usage,
348        // A bounded scan can deliberately fail closed for a malformed or
349        // excessively large tree. Keep the narrow recovery path available or
350        // the agent could never repair the condition that blocked the scan.
351        Err(_) if recovery_command => return Ok(()),
352        Err(error) => return Err(error),
353    };
354    if usage.requires_cleanup() && !recovery_command {
355        return Err(usage.as_budget_error());
356    }
357    let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
358    paths.extend(embedded_writer_paths(command));
359    paths.sort();
360    paths.dedup();
361
362    let mut managed_targets = Vec::new();
363    for path in paths {
364        if let Some(target) = validate_bash_write_target(workspace_root, cwd, &path, &info)? {
365            managed_targets.push(target);
366        }
367    }
368    validate_known_bash_targets_quota(&info, &managed_targets, TempTreeUsage::from(&usage))?;
369    Ok(())
370}
371
372/// Once a dynamic Bash command has exceeded the active-session quota, admit
373/// only narrow inspection/cleanup commands until the directory is back under budget.
374/// This is a recovery gate, not a shell sandbox; the normal mode allowlist still
375/// authorizes the command itself.
376fn is_explicit_temp_recovery(command: &str, info: &AgentTempInfo) -> bool {
377    let directory = info.directory.to_string_lossy();
378    let names_active_dir = command.contains("$WINX_TEMP_DIR")
379        || command.contains("${WINX_TEMP_DIR}")
380        || command.contains(directory.as_ref());
381    if !names_active_dir {
382        return false;
383    }
384    let Ok(commands) = crate::utils::bash_parser::extract_command_texts(command) else {
385        return false;
386    };
387    !commands.is_empty()
388        && commands.iter().all(|statement| {
389            let executable = statement
390                .split_whitespace()
391                .next()
392                .and_then(|word| Path::new(word).file_name())
393                .and_then(OsStr::to_str)
394                .unwrap_or_default();
395            matches!(
396                executable,
397                "rm" | "unlink" | "rmdir" | "du" | "ls" | "stat" | "sort" | "head" | "tail" | "wc"
398            ) || (executable == "find" && !statement.contains("-exec"))
399        })
400}
401
402fn validate_bash_write_target(
403    workspace_root: &Path,
404    cwd: &Path,
405    path: &str,
406    info: &AgentTempInfo,
407) -> Result<Option<PathBuf>> {
408    let managed_env_path = expand_temp_env_path(path, info);
409    let expanded: String = managed_env_path.as_ref().map_or_else(
410        || crate::utils::path::expand_user(path),
411        |path| path.to_string_lossy().into_owned(),
412    );
413    let requested = if Path::new(&expanded).is_absolute() {
414        PathBuf::from(&expanded)
415    } else {
416        cwd.join(&expanded)
417    };
418    let resolved = if managed_env_path.is_some() {
419        requested.clone()
420    } else {
421        crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
422            .unwrap_or_else(|_| requested.clone())
423    };
424    let workspace = workspace_root
425        .canonicalize()
426        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
427
428    let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
429    let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
430    let mut managed_target = None;
431    for relative in
432        [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
433    {
434        let Some(Component::Normal(first)) = relative.components().next() else { continue };
435        let first = first.to_string_lossy();
436        if first == ".winx_tmp" || first.starts_with(".winx-") {
437            return Err(policy_error(
438                &requested,
439                info,
440                format!(
441                    "shell writes to Winx helper artifacts at the workspace root are not \
442                     allowed; use WINX_TEMP_DIR={} instead",
443                    info.directory.display()
444                ),
445            ));
446        }
447        if is_temp_relative(relative) {
448            reject_symlinked_temp_root(&workspace, &requested, info)?;
449            let target = workspace.join(relative);
450            if target != info.directory {
451                validate_session_relative_path(&target, info)?;
452                managed_target = Some(target);
453            }
454        }
455    }
456    Ok(managed_target)
457}
458
459/// Expand only Winx's server-owned temporary path variable. Other shell
460/// expansions remain dynamic and are intentionally left to the shell.
461fn expand_temp_env_path(path: &str, info: &AgentTempInfo) -> Option<PathBuf> {
462    let path = path.trim();
463    let path = if path.len() >= 2
464        && matches!(
465            (path.as_bytes().first(), path.as_bytes().last()),
466            (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
467        ) {
468        &path[1..path.len() - 1]
469    } else {
470        path
471    };
472    let suffix =
473        path.strip_prefix("$WINX_TEMP_DIR").or_else(|| path.strip_prefix("${WINX_TEMP_DIR}"))?;
474    if !suffix.is_empty() && !suffix.starts_with('/') {
475        return None;
476    }
477    let suffix = suffix.trim_start_matches('/');
478    Some(if suffix.is_empty() { info.directory.clone() } else { info.directory.join(suffix) })
479}
480
481fn embedded_writer_paths(command: &str) -> Vec<String> {
482    let lower = command.to_ascii_lowercase();
483    let explicit_writer = [
484        ".write_text(",
485        ".write_bytes(",
486        "writefilesync(",
487        "appendfilesync(",
488        "writefile(",
489        "appendfile(",
490        "bun.write(",
491        "deno.write",
492        "file.write(",
493        "file.binwrite(",
494    ]
495    .iter()
496    .any(|marker| lower.contains(marker));
497    let writable_open = lower.contains("open(")
498        && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
499            .iter()
500            .any(|mode| lower.contains(mode));
501    if !explicit_writer && !writable_open {
502        return Vec::new();
503    }
504
505    quoted_literals(command)
506        .into_iter()
507        .filter(|literal| literal.contains(".winx") || literal.contains("$WINX_TEMP_DIR"))
508        .collect()
509}
510
511fn quoted_literals(value: &str) -> Vec<String> {
512    let mut literals = Vec::new();
513    let mut chars = value.chars();
514    while let Some(character) = chars.next() {
515        if character != '\'' && character != '"' {
516            continue;
517        }
518        let quote = character;
519        let mut literal = String::new();
520        let mut escaped = false;
521        for character in chars.by_ref() {
522            if escaped {
523                literal.push(character);
524                escaped = false;
525            } else if character == '\\' && quote == '"' {
526                escaped = true;
527            } else if character == quote {
528                break;
529            } else {
530                literal.push(character);
531            }
532        }
533        if !literal.is_empty() {
534            literals.push(literal);
535        }
536    }
537    literals
538}
539
540fn workspace_relative(
541    path: &Path,
542    workspace_root: &Path,
543    canonical_workspace: &Path,
544) -> Option<PathBuf> {
545    path.strip_prefix(canonical_workspace)
546        .or_else(|_| path.strip_prefix(workspace_root))
547        .ok()
548        .map(Path::to_path_buf)
549}
550
551/// Enforce the session boundary for an explicit `CodeMap` path.
552///
553/// Returns `true` only for a helper in the active session. A request spelling a
554/// temporary path that resolves elsewhere (for example through a symlink) is
555/// rejected instead of being misclassified as canonical source.
556pub fn validate_code_map_target(
557    workspace_root: &Path,
558    thread_id: &str,
559    requested_path: &Path,
560    resolved_path: &Path,
561) -> Result<bool> {
562    let info = session_info(workspace_root, thread_id);
563    let workspace = workspace_root
564        .canonicalize()
565        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
566    let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
567    let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
568    let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
569    let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
570    if !requested_temp && !resolved_temp {
571        return Ok(false);
572    }
573
574    reject_symlinked_temp_root(&workspace, requested_path, &info)?;
575    if requested_temp != resolved_temp {
576        return Err(policy_error(
577            requested_path,
578            &info,
579            "a temporary helper must not resolve through a symlink outside its managed session"
580                .to_string(),
581        ));
582    }
583
584    let requested_policy = requested_relative
585        .as_deref()
586        .map_or_else(|| requested_path.to_path_buf(), |relative| workspace.join(relative));
587    let resolved_policy = resolved_relative
588        .as_deref()
589        .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
590    if requested_policy != resolved_policy {
591        return Err(policy_error(
592            requested_path,
593            &info,
594            "temporary helper paths must resolve directly without aliases, parent traversal, or symlinks"
595                .to_string(),
596        ));
597    }
598    if requested_policy != info.directory {
599        validate_session_relative_path(&requested_policy, &info)?;
600    }
601    Ok(true)
602}
603
604/// Reserve one syntax-navigation call over an active non-canonical helper.
605pub fn reserve_derived_code_map(
606    usage: &mut DerivedCodeMapUsage,
607    path: &Path,
608    info: &AgentTempInfo,
609) -> Result<DerivedCodeMapPermit> {
610    let is_new = !usage.mapped_files.contains(path);
611    let unique_files = usage.mapped_files.len();
612    if usage.calls >= MAX_DERIVED_CODE_MAP_CALLS
613        || (is_new && unique_files >= MAX_DERIVED_CODE_MAP_UNIQUE_FILES)
614    {
615        return Err(WinxError::DerivedCodeMapBudget {
616            path: path.to_path_buf(),
617            temporary_artifact_dir: info.directory.clone(),
618            calls_used: usage.calls,
619            calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
620            unique_files_used: unique_files,
621            unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
622            message: format!(
623                "this session already used {} derived-helper map calls across {} unique files; \
624                 reuse prior results and inspect canonical source with CodeMap/ReadFiles or rg \
625                 instead of creating another carrier",
626                usage.calls, unique_files
627            ),
628        });
629    }
630
631    usage.calls += 1;
632    usage.mapped_files.insert(path.to_path_buf());
633    Ok(DerivedCodeMapPermit {
634        calls_used: usage.calls,
635        calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
636        unique_files_used: usage.mapped_files.len(),
637        unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
638    })
639}
640
641fn validate_known_bash_targets_quota(
642    info: &AgentTempInfo,
643    targets: &[PathBuf],
644    current: TempTreeUsage,
645) -> Result<()> {
646    if targets.is_empty() {
647        return Ok(());
648    }
649    let new_files = targets.iter().filter(|path| !path.exists()).collect::<BTreeSet<_>>().len();
650    let projected =
651        TempTreeUsage { session_files: current.session_files.saturating_add(new_files), ..current };
652    validate_projected_usage(&targets[0], info, current, projected)
653}
654
655/// Validate the aggregate size of a `MultiFileEdit` after every target has been
656/// planned and duplicate paths have been rejected.
657pub fn validate_batch_quota(
658    workspace_root: &Path,
659    thread_id: &str,
660    edits: &[TempEdit<'_>],
661) -> Result<()> {
662    let info = session_info(workspace_root, thread_id);
663    let managed: Vec<_> =
664        edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
665    if managed.is_empty() {
666        return Ok(());
667    }
668    let representative = managed[0].path;
669
670    let workspace = workspace_root
671        .canonicalize()
672        .map_or_else(|_| workspace_root.to_path_buf(), crate::utils::path::simplified);
673    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
674    let pruned = prune_active_session_unlocked(&info.directory, MAX_AGE);
675    log_active_prune(&info.directory, pruned);
676    let current =
677        temp_tree_usage(&workspace.join(temp_root()), &info.directory).ok_or_else(|| {
678            policy_error(
679                representative,
680                &info,
681                "temporary storage could not be measured safely; remove malformed or excessive \
682             artifacts before retrying"
683                    .to_string(),
684            )
685        })?;
686    let mut projected_total_bytes = current.total_bytes;
687    let mut projected_session_bytes = current.session_bytes;
688    let mut projected_session_files = current.session_files;
689    for edit in managed {
690        projected_total_bytes = projected_total_bytes
691            .saturating_sub(edit.previous_bytes)
692            .saturating_add(edit.new_bytes);
693        projected_session_bytes = projected_session_bytes
694            .saturating_sub(edit.previous_bytes)
695            .saturating_add(edit.new_bytes);
696        if edit.is_new {
697            projected_session_files = projected_session_files.saturating_add(1);
698        }
699    }
700    validate_projected_usage(
701        representative,
702        &info,
703        current,
704        TempTreeUsage {
705            total_bytes: projected_total_bytes,
706            session_bytes: projected_session_bytes,
707            session_files: projected_session_files,
708            largest_file_bytes: current.largest_file_bytes,
709        },
710    )
711}
712
713fn session_name(thread_id: &str) -> String {
714    use std::fmt::Write as _;
715
716    let digest = Sha256::digest(thread_id.as_bytes());
717    let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
718    for byte in &digest[..SESSION_HASH_BYTES] {
719        let _ = write!(suffix, "{byte:02x}");
720    }
721    format!("{SESSION_PREFIX}{suffix}")
722}
723
724fn reject_legacy_root_artifact(
725    relative: &Path,
726    is_new: bool,
727    requested_path: &Path,
728    info: &AgentTempInfo,
729) -> Result<()> {
730    let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
731    let first = first.to_string_lossy();
732    if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
733        return Err(policy_error(
734            requested_path,
735            info,
736            format!(
737                "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
738                 short descriptive filename",
739                info.directory.display()
740            ),
741        ));
742    }
743    Ok(())
744}
745
746fn is_temp_relative(relative: &Path) -> bool {
747    let mut components = relative.components();
748    matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
749        && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
750}
751
752fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
753    let temp_root = workspace.join(temp_root());
754    if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
755        return Err(policy_error(
756            path,
757            info,
758            format!(
759                "{} is a symlink; the managed temporary root must be a real directory inside \
760                 the workspace",
761                temp_root.display()
762            ),
763        ));
764    }
765    Ok(())
766}
767
768fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
769    let relative = path.strip_prefix(&info.directory).map_err(|_| {
770        policy_error(
771            path,
772            info,
773            format!(
774                "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
775                 returned by Initialize",
776                info.directory.display()
777            ),
778        )
779    })?;
780    let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
781    if relative_bytes == 0 {
782        return Err(policy_error(path, info, "a helper filename is required".to_string()));
783    }
784    if relative_bytes > MAX_RELATIVE_PATH_BYTES {
785        return Err(policy_error(
786            path,
787            info,
788            format!(
789                "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
790                 to {MAX_RELATIVE_PATH_BYTES} bytes"
791            ),
792        ));
793    }
794
795    let mut count = 0usize;
796    for component in relative.components() {
797        let Component::Normal(name) = component else {
798            return Err(policy_error(
799                path,
800                info,
801                "temporary paths may contain only normal filename components".to_string(),
802            ));
803        };
804        if name.as_encoded_bytes().starts_with(b".winx-release-") {
805            return Err(policy_error(
806                path,
807                info,
808                "release quarantine is reserved for recovery; file mutations are denied".into(),
809            ));
810        }
811        count += 1;
812        let bytes = name.as_encoded_bytes().len();
813        if bytes > MAX_COMPONENT_BYTES {
814            return Err(policy_error(
815                path,
816                info,
817                format!(
818                    "a temporary path component is {bytes} bytes; components are limited to \
819                     {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
820                ),
821            ));
822        }
823    }
824    if count > MAX_RELATIVE_COMPONENTS {
825        return Err(policy_error(
826            path,
827            info,
828            format!(
829                "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
830                 are allowed beneath the session directory"
831            ),
832        ));
833    }
834    Ok(())
835}
836
837fn validate_projected_usage(
838    path: &Path,
839    info: &AgentTempInfo,
840    current: TempTreeUsage,
841    projected: TempTreeUsage,
842) -> Result<()> {
843    let violation =
844        if projected.total_bytes > MAX_TOTAL_BYTES && projected.total_bytes > current.total_bytes {
845            Some(format!(
846                "this write would grow managed temporary storage to {} bytes; the workspace-wide \
847             limit is {MAX_TOTAL_BYTES} bytes",
848                projected.total_bytes
849            ))
850        } else if projected.session_bytes > MAX_SESSION_BYTES
851            && projected.session_bytes > current.session_bytes
852        {
853            Some(format!(
854                "this write would grow the active session's temporary storage to {} bytes; the \
855             per-session limit is {MAX_SESSION_BYTES} bytes",
856                projected.session_bytes
857            ))
858        } else if projected.session_files > MAX_SESSION_FILES
859            && projected.session_files > current.session_files
860        {
861            Some(format!(
862            "this write would grow the active session to {} helper files; the per-session limit \
863             is {MAX_SESSION_FILES}. Reuse or overwrite a stable helper and remove obsolete \
864             carriers instead of creating another file",
865            projected.session_files
866        ))
867        } else {
868            None
869        };
870    match violation {
871        Some(message) => Err(policy_error(
872            path,
873            info,
874            format!(
875                "{message}. Shrinking existing helpers remains allowed so the session can recover"
876            ),
877        )),
878        None => Ok(()),
879    }
880}
881
882fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
883    WinxError::TemporaryArtifactPolicy {
884        path: path.to_path_buf(),
885        temporary_artifact_dir: info.directory.clone(),
886        message,
887    }
888}
889
890#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
891struct TempTreeUsage {
892    total_bytes: u64,
893    session_bytes: u64,
894    session_files: usize,
895    largest_file_bytes: u64,
896}
897
898impl From<&TemporaryArtifactUsage> for TempTreeUsage {
899    fn from(usage: &TemporaryArtifactUsage) -> Self {
900        Self {
901            total_bytes: usage.total_bytes,
902            session_bytes: usage.session_bytes,
903            session_files: usage.session_files,
904            largest_file_bytes: usage.largest_file_bytes,
905        }
906    }
907}
908
909fn temp_tree_usage(root: &Path, active_session: &Path) -> Option<TempTreeUsage> {
910    let metadata = match fs::symlink_metadata(root) {
911        Ok(metadata) => metadata,
912        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
913            return Some(TempTreeUsage::default())
914        }
915        Err(_) => return None,
916    };
917    if metadata.file_type().is_symlink() {
918        return None;
919    }
920
921    let mut usage = TempTreeUsage::default();
922    let mut seen = 0usize;
923    let mut stack = vec![(root.to_path_buf(), 0usize)];
924    while let Some((path, depth)) = stack.pop() {
925        if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
926            return None;
927        }
928        seen += 1;
929        let metadata = match fs::symlink_metadata(&path) {
930            Ok(metadata) => metadata,
931            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
932            Err(_) => return None,
933        };
934        // Count symlinks and special nodes as artifacts without following
935        // them; otherwise Bash could evade the file-count budget cheaply.
936        if !metadata.is_dir() {
937            usage.total_bytes = usage.total_bytes.saturating_add(metadata.len());
938            if path.starts_with(active_session) {
939                usage.session_bytes = usage.session_bytes.saturating_add(metadata.len());
940                usage.session_files = usage.session_files.saturating_add(1);
941                usage.largest_file_bytes = usage.largest_file_bytes.max(metadata.len());
942            }
943            continue;
944        }
945        if metadata.is_dir() {
946            let entries = match fs::read_dir(&path) {
947                Ok(entries) => entries,
948                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
949                Err(_) => return None,
950            };
951            for entry in entries {
952                match entry {
953                    Ok(entry) => stack.push((entry.path(), depth + 1)),
954                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
955                    Err(_) => return None,
956                }
957            }
958        }
959    }
960    Some(usage)
961}
962
963#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
964struct PrunedArtifacts {
965    files: usize,
966    bytes: u64,
967}
968
969fn prune_active_session_unlocked(_active: &Path, _max_age: Duration) -> PrunedArtifacts {
970    // This utility has no authoritative runtime owner. Age cannot prove idle.
971    PrunedArtifacts::default()
972}
973
974fn log_active_prune(active: &Path, pruned: PrunedArtifacts) {
975    if pruned.files > 0 {
976        info!(
977            temporary_artifact_dir = %active.display(),
978            stale_pruned_files = pruned.files,
979            stale_pruned_bytes = pruned.bytes,
980            "pruned stale active-session temporary artifacts at high water mark"
981        );
982    }
983}
984
985fn prune_sessions_unlocked(_workspace_root: &Path, _active: &Path, _max_age: Duration) {
986    // Sibling guardians may survive this frontend. Unknown protection retains files.
987}
988
989#[cfg(test)]
990mod tests {
991    #![allow(clippy::expect_used, clippy::unwrap_used)]
992
993    use super::*;
994    use std::thread::sleep;
995    use tempfile::TempDir;
996
997    #[test]
998    fn session_path_is_stable_short_and_workspace_local() {
999        let workspace = TempDir::new().unwrap();
1000        let first = session_info(workspace.path(), "chat/project:one");
1001        let repeated = session_info(workspace.path(), "chat/project:one");
1002        let other = session_info(workspace.path(), "chat/project:two");
1003        assert_eq!(first, repeated);
1004        assert_ne!(first.directory, other.directory);
1005        assert!(first.directory.starts_with(
1006            workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap()
1007        ));
1008        let name = first.directory.file_name().unwrap().to_string_lossy();
1009        assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
1010    }
1011
1012    #[test]
1013    fn accepts_short_helpers_only_in_the_active_session() {
1014        let workspace = TempDir::new().unwrap();
1015        let info = session_info(workspace.path(), "active");
1016        let valid = info.directory.join("review/adapter.py");
1017        validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
1018
1019        let other = session_info(workspace.path(), "other").directory.join("adapter.py");
1020        let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
1021            .expect_err("cross-session temp write must fail");
1022        assert!(error.to_string().contains("session-scoped"), "{error}");
1023    }
1024
1025    #[test]
1026    fn bash_writes_accept_only_the_active_session_temp_path() {
1027        let workspace = TempDir::new().unwrap();
1028        let info = session_info(workspace.path(), "active");
1029        let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
1030        validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
1031            .expect("active session destination must be accepted");
1032
1033        for command in [
1034            "printf x > .winx/tmp/direct.ts",
1035            "printf x | tee .winx-review-carrier.js",
1036            "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
1037        ] {
1038            let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
1039                .expect_err("unmanaged shell helper must fail");
1040            assert!(
1041                error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
1042                "{error}"
1043            );
1044        }
1045    }
1046
1047    #[test]
1048    fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
1049        let workspace = TempDir::new().unwrap();
1050        for command in [
1051            "rg needle .winx/tmp/direct.ts",
1052            "cat .winx-review-carrier.js",
1053            "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
1054        ] {
1055            validate_bash_command(workspace.path(), workspace.path(), "active", command)
1056                .expect("read or runtime-provided destination must remain available");
1057        }
1058    }
1059
1060    #[test]
1061    fn rejects_legacy_root_artifacts_and_payload_names() {
1062        let workspace = TempDir::new().unwrap();
1063        let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
1064        let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
1065            .expect_err("legacy root must fail");
1066        assert!(error.to_string().contains("workspace root"), "{error}");
1067
1068        let info = session_info(workspace.path(), "active");
1069        let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
1070        let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
1071            .expect_err("long component must fail");
1072        assert!(error.to_string().contains("filesystem names"), "{error}");
1073    }
1074
1075    #[test]
1076    fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
1077        let workspace = TempDir::new().unwrap();
1078        let requested = workspace.path().join("nested/../.winx-review-carrier.py");
1079        let resolved = workspace.path().join(".winx-review-carrier.py");
1080        let error =
1081            validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
1082                .expect_err("resolved top-level artifact must fail");
1083        assert!(error.to_string().contains("workspace root"), "{error}");
1084    }
1085
1086    #[cfg(unix)]
1087    #[test]
1088    fn lexical_workspace_alias_cannot_bypass_temp_policy() {
1089        use std::os::unix::fs::symlink;
1090
1091        let parent = TempDir::new().unwrap();
1092        let real_workspace = parent.path().join("real-workspace");
1093        let alias_workspace = parent.path().join("workspace-alias");
1094        fs::create_dir(&real_workspace).unwrap();
1095        symlink(&real_workspace, &alias_workspace).unwrap();
1096
1097        let legacy = alias_workspace.join(".winx_tmp/payload.txt");
1098        let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
1099            .expect_err("a lexical workspace alias must not bypass root-artifact policy");
1100        assert!(error.to_string().contains("workspace root"), "{error}");
1101
1102        let canonical_workspace =
1103            alias_workspace.canonicalize().map(crate::utils::path::simplified).unwrap();
1104        let canonical_session = session_info(&alias_workspace, "active").directory;
1105        let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
1106        let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
1107        validate_edit_target(
1108            &alias_workspace,
1109            "active",
1110            &aliased_helper,
1111            &aliased_helper,
1112            None,
1113            10,
1114        )
1115        .expect("the active managed directory must work through the lexical alias");
1116    }
1117
1118    #[test]
1119    fn unguarded_maintenance_retains_expired_sibling_sessions() {
1120        let workspace = TempDir::new().unwrap();
1121        let active = session_info(workspace.path(), "active").directory;
1122        let expired = session_info(workspace.path(), "expired").directory;
1123        let unmanaged = workspace.path().join(temp_root()).join("manual-cache");
1124        fs::create_dir_all(&active).unwrap();
1125        fs::create_dir_all(&expired).unwrap();
1126        fs::create_dir_all(&unmanaged).unwrap();
1127        fs::write(expired.join("old.txt"), "old").unwrap();
1128        sleep(Duration::from_millis(15));
1129
1130        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1131        prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
1132        assert!(active.exists());
1133        assert!(expired.exists());
1134        assert!(unmanaged.exists());
1135    }
1136
1137    #[test]
1138    fn unguarded_maintenance_retains_stale_helpers_above_high_water_mark() {
1139        let workspace = TempDir::new().unwrap();
1140        let active = session_info(workspace.path(), "active").directory;
1141        fs::create_dir_all(active.join("old/nested")).unwrap();
1142        for index in 0..ACTIVE_SESSION_PRUNE_TRIGGER_FILES {
1143            fs::write(active.join("old/nested").join(format!("old-{index}.txt")), "old").unwrap();
1144        }
1145        sleep(Duration::from_millis(15));
1146        let fresh =
1147            (0..8).map(|index| active.join(format!("fresh-{index}.txt"))).collect::<Vec<_>>();
1148        for path in &fresh {
1149            fs::write(path, "fresh").unwrap();
1150        }
1151
1152        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1153        let pruned = prune_active_session_unlocked(&active, Duration::from_millis(5));
1154        assert_eq!(pruned.files, 0);
1155        // Mirror production (`audit_session_impl`): the scan root must come
1156        // from the canonicalized workspace, because `active` is canonical too.
1157        // A literal root diverges when the temp dir sits behind a symlink
1158        // (containerized CI), making `starts_with(active)` count zero session
1159        // files while the helpers all still exist.
1160        let usage_root = workspace
1161            .path()
1162            .canonicalize()
1163            .map_or_else(|_| workspace.path().to_path_buf(), crate::utils::path::simplified)
1164            .join(temp_root());
1165        assert_eq!(
1166            temp_tree_usage(&usage_root, &active).unwrap().session_files,
1167            ACTIVE_SESSION_PRUNE_TRIGGER_FILES + 8
1168        );
1169        assert!(fresh.iter().all(|path| path.exists()), "fresh helpers must be retained");
1170    }
1171
1172    #[test]
1173    fn active_session_does_not_prune_stale_helpers_below_high_water_mark() {
1174        let workspace = TempDir::new().unwrap();
1175        let active = session_info(workspace.path(), "active").directory;
1176        fs::create_dir_all(&active).unwrap();
1177        for index in 0..(ACTIVE_SESSION_PRUNE_TRIGGER_FILES - 1) {
1178            fs::write(active.join(format!("helper-{index}.txt")), "old").unwrap();
1179        }
1180        sleep(Duration::from_millis(10));
1181
1182        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1183        let pruned = prune_active_session_unlocked(&active, Duration::from_millis(1));
1184        assert_eq!(pruned, PrunedArtifacts::default());
1185        assert_eq!(fs::read_dir(active).unwrap().count(), ACTIVE_SESSION_PRUNE_TRIGGER_FILES - 1);
1186    }
1187
1188    #[cfg(unix)]
1189    #[test]
1190    fn active_session_pruning_never_follows_symlink_helpers() {
1191        use std::os::unix::fs::symlink;
1192
1193        let workspace = TempDir::new().unwrap();
1194        let active = session_info(workspace.path(), "active").directory;
1195        let outside = workspace.path().join("canonical.txt");
1196        fs::create_dir_all(&active).unwrap();
1197        fs::write(&outside, "keep").unwrap();
1198        for index in 0..ACTIVE_SESSION_PRUNE_TRIGGER_FILES {
1199            symlink(&outside, active.join(format!("link-{index}"))).unwrap();
1200        }
1201        sleep(Duration::from_millis(10));
1202
1203        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1204        let pruned = prune_active_session_unlocked(&active, Duration::from_millis(1));
1205        assert_eq!(pruned.files, 0);
1206        assert_eq!(fs::read_to_string(outside).unwrap(), "keep");
1207    }
1208
1209    #[test]
1210    fn quota_projection_accounts_for_replacements() {
1211        let workspace = TempDir::new().unwrap();
1212        let info = session_info(workspace.path(), "active");
1213        let path = info.directory.join("large.bin");
1214        let current = TempTreeUsage::default();
1215        assert!(validate_projected_usage(
1216            &path,
1217            &info,
1218            current,
1219            TempTreeUsage { total_bytes: MAX_TOTAL_BYTES, ..current }
1220        )
1221        .is_ok());
1222        assert!(validate_projected_usage(
1223            &path,
1224            &info,
1225            current,
1226            TempTreeUsage { total_bytes: MAX_TOTAL_BYTES + 1, ..current }
1227        )
1228        .is_err());
1229
1230        let oversized = TempTreeUsage {
1231            total_bytes: MAX_TOTAL_BYTES + 10,
1232            session_bytes: MAX_SESSION_BYTES + 10,
1233            session_files: MAX_SESSION_FILES + 10,
1234            largest_file_bytes: MAX_FILE_BYTES + 10,
1235        };
1236        let shrinking = TempTreeUsage {
1237            total_bytes: oversized.total_bytes - 1,
1238            session_bytes: oversized.session_bytes - 1,
1239            session_files: oversized.session_files,
1240            largest_file_bytes: oversized.largest_file_bytes,
1241        };
1242        validate_projected_usage(&path, &info, oversized, shrinking)
1243            .expect("an over-limit session must be allowed to shrink existing helpers");
1244    }
1245
1246    #[test]
1247    fn batch_quota_counts_every_planned_helper_before_committing() {
1248        let workspace = TempDir::new().unwrap();
1249        let info = session_info(workspace.path(), "active");
1250        let paths = (0..9)
1251            .map(|index| info.directory.join(format!("helper-{index}.bin")))
1252            .collect::<Vec<_>>();
1253        let edits = paths
1254            .iter()
1255            .map(|path| TempEdit {
1256                path,
1257                previous_bytes: 0,
1258                new_bytes: MAX_FILE_BYTES,
1259                is_new: true,
1260            })
1261            .collect::<Vec<_>>();
1262
1263        let error = validate_batch_quota(workspace.path(), "active", &edits)
1264            .expect_err("aggregate batch must honor the workspace quota");
1265        assert!(error.to_string().contains("workspace-wide limit"), "{error}");
1266    }
1267
1268    #[test]
1269    fn literal_winx_temp_destinations_honor_the_session_file_cap() {
1270        let workspace = TempDir::new().unwrap();
1271        let info = session_info(workspace.path(), "active");
1272        fs::create_dir_all(&info.directory).unwrap();
1273        for index in 0..MAX_SESSION_FILES {
1274            fs::write(info.directory.join(format!("helper-{index}.txt")), "x").unwrap();
1275        }
1276
1277        validate_bash_command(
1278            workspace.path(),
1279            workspace.path(),
1280            "active",
1281            "printf replacement > \"$WINX_TEMP_DIR/helper-0.txt\"",
1282        )
1283        .expect("overwriting a stable helper must remain possible at the cap");
1284        let error = validate_bash_command(
1285            workspace.path(),
1286            workspace.path(),
1287            "active",
1288            "printf new > \"$WINX_TEMP_DIR/one-more.txt\"",
1289        )
1290        .expect_err("a visible new shell helper must honor the cap");
1291        assert!(error.to_string().contains("helper files"), "{error}");
1292    }
1293
1294    #[test]
1295    fn dynamic_bash_overflow_blocks_work_until_explicit_cleanup() {
1296        let workspace = TempDir::new().unwrap();
1297        let info = session_info(workspace.path(), "active");
1298        fs::create_dir_all(&info.directory).unwrap();
1299        for index in 0..=MAX_SESSION_FILES {
1300            fs::write(info.directory.join(format!("dynamic-{index}.txt")), "x").unwrap();
1301        }
1302
1303        let usage = audit_session(workspace.path(), "active").unwrap();
1304        assert!(usage.over_budget);
1305        assert!(usage.requires_cleanup());
1306        let error =
1307            validate_bash_command(workspace.path(), workspace.path(), "active", "cargo test --lib")
1308                .expect_err(
1309                    "ordinary commands must pause while the active temp session is over budget",
1310                );
1311        assert!(matches!(error, WinxError::TemporaryArtifactBudgetExceeded { .. }));
1312
1313        validate_bash_command(
1314            workspace.path(),
1315            workspace.path(),
1316            "active",
1317            "find \"$WINX_TEMP_DIR\" -type f -delete",
1318        )
1319        .expect("an explicit cleanup-only command must remain available");
1320    }
1321
1322    #[cfg(unix)]
1323    #[test]
1324    fn symlink_helpers_count_toward_the_session_artifact_budget() {
1325        use std::os::unix::fs::symlink;
1326
1327        let workspace = TempDir::new().unwrap();
1328        let info = session_info(workspace.path(), "active");
1329        fs::create_dir_all(&info.directory).unwrap();
1330        for index in 0..=MAX_SESSION_FILES {
1331            symlink("missing-target", info.directory.join(format!("link-{index}"))).unwrap();
1332        }
1333
1334        let usage = audit_session(workspace.path(), "active").unwrap();
1335        assert_eq!(usage.session_files, MAX_SESSION_FILES + 1);
1336        assert!(usage.requires_cleanup());
1337    }
1338
1339    #[test]
1340    fn code_map_accepts_only_the_active_direct_helper_path() {
1341        let workspace = TempDir::new().unwrap();
1342        let active = session_info(workspace.path(), "active");
1343        let helper = active.directory.join("adapter.py");
1344        assert!(validate_code_map_target(workspace.path(), "active", &helper, &helper).unwrap());
1345
1346        let foreign = session_info(workspace.path(), "other").directory.join("adapter.py");
1347        let error = validate_code_map_target(workspace.path(), "active", &foreign, &foreign)
1348            .expect_err("another session's helper must not be mapped");
1349        assert!(error.to_string().contains("session-scoped"), "{error}");
1350
1351        let canonical = workspace.path().join("src/lib.rs");
1352        assert!(
1353            !validate_code_map_target(workspace.path(), "active", &canonical, &canonical).unwrap()
1354        );
1355    }
1356
1357    #[test]
1358    fn derived_code_map_budget_rewards_reuse_and_caps_churn() {
1359        let workspace = TempDir::new().unwrap();
1360        let info = session_info(workspace.path(), "active");
1361        let mut usage = DerivedCodeMapUsage::default();
1362        let stable = info.directory.join("stable.py");
1363        let first = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1364        let repeated = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1365        assert_eq!(first.unique_files_used, 1);
1366        assert_eq!(repeated.unique_files_used, 1);
1367        assert_eq!(repeated.calls_used, 2);
1368
1369        for index in 1..MAX_DERIVED_CODE_MAP_UNIQUE_FILES {
1370            reserve_derived_code_map(
1371                &mut usage,
1372                &info.directory.join(format!("helper-{index}.py")),
1373                &info,
1374            )
1375            .unwrap();
1376        }
1377        let error =
1378            reserve_derived_code_map(&mut usage, &info.directory.join("one-too-many.py"), &info)
1379                .expect_err("a new carrier past the unique-file budget must fail");
1380        assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1381
1382        let mut repeated_usage = DerivedCodeMapUsage::default();
1383        for _ in 0..MAX_DERIVED_CODE_MAP_CALLS {
1384            reserve_derived_code_map(&mut repeated_usage, &stable, &info).unwrap();
1385        }
1386        let error = reserve_derived_code_map(&mut repeated_usage, &stable, &info)
1387            .expect_err("even one stable helper has an aggregate call budget");
1388        assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1389    }
1390}
1391
1392#[cfg(all(test, target_os = "linux"))]
1393mod quarantine_namespace_tests {
1394    use super::*;
1395
1396    #[tokio::test]
1397    async fn normal_edit_files_cannot_write_the_release_quarantine() -> anyhow::Result<()> {
1398        let temporary = tempfile::tempdir()?;
1399        let workspace = temporary.path().canonicalize()?;
1400        let quarantine =
1401            session_info(&workspace, "quarantine").directory.join(".winx-release-test");
1402        fs::create_dir_all(&quarantine)?;
1403        let target = quarantine.join("artifact");
1404        let mut state = crate::state::bash_state::BashState::new();
1405        state.current_thread_id = "quarantine".into();
1406        state.workspace_root = workspace.clone();
1407        state.cwd = workspace.clone();
1408        state.mode = crate::types::Modes::Wcgw;
1409        let normalized = crate::tools::edit_files::normalize_edit_call(
1410            crate::tools::edit_files::EditSurface::EditFiles,
1411            serde_json::json!({"thread_id":"quarantine", "workspace_root":workspace,
1412                "files":[{"file_path":target,"mode":"replace","content":"must not replace captured bytes"}]}),
1413        )?;
1414        let result = async {
1415            let prepared = crate::tools::edit_files::PreparedEditContext::prepare(
1416                normalized,
1417                &state,
1418                crate::tool_policy::ToolPolicy::default().edit_permissions(),
1419            )?;
1420            let slot = std::sync::Arc::new(tokio::sync::Mutex::new(Some(state)));
1421            crate::tools::edit_files::handle_prepared(&slot, prepared).await
1422        }
1423        .await;
1424        assert!(result.is_err(), "ordinary file writes must not enter the private claim namespace");
1425        assert!(!target.exists());
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn resolved_alias_cannot_authorize_a_quarantine_write() -> anyhow::Result<()> {
1431        let temporary = tempfile::tempdir()?;
1432        let workspace = temporary.path().canonicalize()?;
1433        let quarantine =
1434            session_info(&workspace, "quarantine").directory.join(".winx-release-test");
1435        fs::create_dir_all(&quarantine)?;
1436        let target = quarantine.join("artifact");
1437        fs::write(&target, "captured")?;
1438        let alias = workspace.join("ordinary-alias");
1439        std::os::unix::fs::symlink(&quarantine, &alias)?;
1440        assert!(validate_edit_target(
1441            &workspace,
1442            "quarantine",
1443            &alias.join("artifact"),
1444            &target,
1445            Some(8),
1446            10
1447        )
1448        .is_err());
1449        assert_eq!(fs::read_to_string(target)?, "captured");
1450        Ok(())
1451    }
1452}
1453
1454#[cfg(all(test, target_os = "linux"))]
1455mod quarantine_undo_namespace_tests {
1456    #[test]
1457    fn undo_preflight_cannot_authorize_a_quarantined_artifact() -> anyhow::Result<()> {
1458        let temporary = tempfile::tempdir()?;
1459        let workspace = temporary.path().canonicalize()?;
1460        let quarantine =
1461            super::session_info(&workspace, "quarantine").directory.join(".winx-release-test");
1462        std::fs::create_dir_all(&quarantine)?;
1463        let target = quarantine.join("artifact");
1464        std::fs::write(&target, "captured")?;
1465        let mut state = crate::state::bash_state::BashState::new();
1466        state.current_thread_id = "quarantine".into();
1467        state.workspace_root = workspace.clone();
1468        state.cwd = workspace.clone();
1469        state.mode = crate::types::Modes::Wcgw;
1470        let normalized = crate::tools::edit_files::normalize_edit_call(
1471            crate::tools::edit_files::EditSurface::EditFiles,
1472            serde_json::json!({"thread_id":"quarantine", "workspace_root":workspace,
1473                "operation":"undo", "files":[{"file_path":target,"mode":"undo","undo_id":"unused-checkpoint"}]}),
1474        )?;
1475        let result = crate::tools::edit_files::PreparedEditContext::prepare(
1476            normalized,
1477            &state,
1478            crate::tool_policy::ToolPolicy::default().edit_permissions(),
1479        );
1480        let error = result.err().ok_or_else(|| {
1481            anyhow::anyhow!("Undo must deny the reserved namespace before checkpoint lookup")
1482        })?;
1483        assert!(error.to_string().contains("quarantine is reserved"), "{error}");
1484        assert_eq!(std::fs::read_to_string(target)?, "captured");
1485        Ok(())
1486    }
1487}