Skip to main content

nika_engine/tools/
context.rs

1//! Tool Context - Shared state and permissions for file tools
2//!
3//! Manages:
4//! - Working directory (security boundary)
5//! - Read files tracking (for edit validation)
6//! - Permission mode (Plan, AcceptEdits, YoloMode)
7//! - Event emission for observability
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use parking_lot::RwLock;
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc;
15
16use super::ToolErrorCode;
17use crate::error::NikaError;
18
19// ═══════════════════════════════════════════════════════════════════════════
20// PERMISSION MODE
21// ═══════════════════════════════════════════════════════════════════════════
22
23/// Permission mode for tool operations
24///
25/// Inspired by Gemini CLI's Yolo Mode and Claude Code's permission levels.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum PermissionMode {
28    /// Deny all file operations
29    Deny,
30
31    /// Ask before each operation (Plan mode)
32    ///
33    /// Returns `PermissionDenied` and emits a permission request event.
34    /// The UI should handle this and prompt the user.
35    #[default]
36    Plan,
37
38    /// Auto-approve edits, ask for create/delete
39    ///
40    /// Good for refactoring sessions where edits are trusted.
41    AcceptEdits,
42
43    /// Auto-approve all operations (Yolo mode)
44    ///
45    /// Use with caution! Best for sandboxed environments.
46    YoloMode,
47}
48
49impl PermissionMode {
50    /// Check if an operation is allowed
51    pub fn allows(&self, operation: ToolOperation) -> bool {
52        match self {
53            PermissionMode::Deny => false,
54            PermissionMode::Plan => false, // Always ask
55            PermissionMode::AcceptEdits => matches!(operation, ToolOperation::Edit),
56            PermissionMode::YoloMode => true,
57        }
58    }
59
60    /// Get display name
61    pub fn display_name(&self) -> &'static str {
62        match self {
63            PermissionMode::Deny => "Deny",
64            PermissionMode::Plan => "Plan",
65            PermissionMode::AcceptEdits => "AcceptEdits",
66            PermissionMode::YoloMode => "YoloMode (Yolo)",
67        }
68    }
69}
70
71/// Type of tool operation for permission checking
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ToolOperation {
74    /// Reading a file (usually safe)
75    Read,
76    /// Creating a new file
77    Write,
78    /// Modifying an existing file
79    Edit,
80    /// Searching files (glob/grep)
81    Search,
82}
83
84// ═══════════════════════════════════════════════════════════════════════════
85// TOOL EVENT
86// ═══════════════════════════════════════════════════════════════════════════
87
88/// Events emitted by tools for observability
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub enum ToolEvent {
91    /// File was read
92    FileRead {
93        path: String,
94        lines: usize,
95        truncated: bool,
96    },
97
98    /// File was written (created)
99    FileWritten { path: String, bytes: usize },
100
101    /// File was edited
102    FileEdited {
103        path: String,
104        replacements: usize,
105        diff_preview: String,
106    },
107
108    /// Glob search completed
109    GlobSearch {
110        pattern: String,
111        matches: usize,
112        base_path: String,
113    },
114
115    /// Grep search completed
116    GrepSearch {
117        pattern: String,
118        files_searched: usize,
119        matches: usize,
120    },
121
122    /// Permission request (for Plan mode)
123    PermissionRequest {
124        operation: String,
125        path: String,
126        details: String,
127    },
128
129    /// Permission granted by user
130    PermissionGranted { operation: String, path: String },
131
132    /// Permission denied by user
133    PermissionDeniedByUser { operation: String, path: String },
134}
135
136// ═══════════════════════════════════════════════════════════════════════════
137// TOOL CONTEXT
138// ═══════════════════════════════════════════════════════════════════════════
139
140/// Shared context for all file tools
141///
142/// Thread-safe via `Arc<ToolContext>` + `RwLock` for mutable state.
143pub struct ToolContext {
144    /// Working directory (security boundary)
145    ///
146    /// All file operations must be within this directory.
147    working_dir: PathBuf,
148
149    /// Files that have been read (for edit validation)
150    ///
151    /// Edit operations require the file to be read first.
152    read_files: RwLock<HashSet<PathBuf>>,
153
154    /// Current permission mode
155    permission_mode: RwLock<PermissionMode>,
156
157    /// Event sender for observability (optional)
158    event_tx: Option<mpsc::Sender<ToolEvent>>,
159}
160
161impl ToolContext {
162    /// Create a new tool context
163    ///
164    /// # Arguments
165    ///
166    /// * `working_dir` - Security boundary for file operations
167    /// * `permission_mode` - Initial permission level
168    pub fn new(working_dir: PathBuf, permission_mode: PermissionMode) -> Self {
169        // Canonicalize working_dir to resolve symlinks (e.g., /var → /private/var on macOS)
170        // This ensures validate_path() comparisons work correctly
171        let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
172        Self {
173            working_dir,
174            read_files: RwLock::new(HashSet::new()),
175            permission_mode: RwLock::new(permission_mode),
176            event_tx: None,
177        }
178    }
179
180    /// Create context with event channel
181    pub fn with_events(mut self, tx: mpsc::Sender<ToolEvent>) -> Self {
182        self.event_tx = Some(tx);
183        self
184    }
185
186    /// Get the working directory
187    pub fn working_dir(&self) -> &Path {
188        &self.working_dir
189    }
190
191    /// Get current permission mode
192    pub fn permission_mode(&self) -> PermissionMode {
193        *self.permission_mode.read()
194    }
195
196    /// Set permission mode
197    pub fn set_permission_mode(&self, mode: PermissionMode) {
198        *self.permission_mode.write() = mode;
199    }
200
201    /// Validate a path is safe to access
202    ///
203    /// Relative paths are resolved against the working directory.
204    /// Returns the canonicalized path if valid.
205    ///
206    /// # Errors
207    ///
208    /// - `PathOutOfBounds` if path is outside working directory
209    pub fn validate_path(&self, file_path: &str) -> Result<PathBuf, NikaError> {
210        let raw_path = PathBuf::from(file_path);
211
212        // Resolve relative paths against working directory
213        let path = if raw_path.is_absolute() {
214            raw_path
215        } else {
216            self.working_dir.join(&raw_path)
217        };
218
219        // Canonicalize to resolve .. and symlinks
220        // For existing files: canonicalize directly
221        // For non-existent files: find first existing ancestor, canonicalize, then append remaining
222        let normalized = if path.exists() {
223            path.canonicalize().unwrap_or(path)
224        } else {
225            // Find the first existing ancestor and canonicalize from there
226            // This handles deeply nested paths like /var/folders/.../nested/deep/file.txt
227            // where /var → /private/var on macOS
228            self.canonicalize_with_ancestors(&path)
229        };
230
231        // Must be within working directory
232        if !normalized.starts_with(&self.working_dir) {
233            return Err(NikaError::ToolError {
234                code: ToolErrorCode::PathOutOfBounds.code(),
235                message: format!(
236                    "Path '{}' is outside working directory '{}'",
237                    file_path,
238                    self.working_dir.display()
239                ),
240            });
241        }
242
243        Ok(normalized)
244    }
245
246    /// Normalize a path without requiring it to exist
247    fn normalize_path(&self, path: &Path) -> PathBuf {
248        let mut components = Vec::new();
249
250        for component in path.components() {
251            match component {
252                std::path::Component::ParentDir => {
253                    components.pop();
254                }
255                std::path::Component::CurDir => {}
256                _ => components.push(component),
257            }
258        }
259
260        components.iter().collect()
261    }
262
263    /// Canonicalize a path by finding the first existing ancestor
264    ///
265    /// This handles paths like `/var/folders/.../nested/deep/file.txt` where:
266    /// - `/var` → `/private/var` on macOS (symlink)
267    /// - `nested/deep/` doesn't exist yet
268    ///
269    /// We walk up until we find an existing directory, canonicalize it,
270    /// then append the remaining components.
271    fn canonicalize_with_ancestors(&self, path: &Path) -> PathBuf {
272        let mut ancestors: Vec<&std::ffi::OsStr> = Vec::new();
273        let mut current = path;
274
275        // Walk up until we find an existing ancestor
276        while !current.exists() {
277            if let Some(file_name) = current.file_name() {
278                ancestors.push(file_name);
279            }
280            if let Some(parent) = current.parent() {
281                current = parent;
282            } else {
283                // No existing ancestor found, fall back to normalize
284                return self.normalize_path(path);
285            }
286        }
287
288        // Canonicalize the existing ancestor
289        let canonical_base = current
290            .canonicalize()
291            .unwrap_or_else(|_| current.to_path_buf());
292
293        // Append the non-existent components in reverse order
294        let mut result = canonical_base;
295        for component in ancestors.into_iter().rev() {
296            result = result.join(component);
297        }
298
299        result
300    }
301
302    /// Check if operation is allowed by current permission mode
303    pub fn check_permission(&self, operation: ToolOperation) -> Result<(), NikaError> {
304        let mode = self.permission_mode();
305
306        if mode.allows(operation) {
307            return Ok(());
308        }
309
310        // For Plan mode, we could emit a permission request event
311        // For now, just deny
312        Err(NikaError::ToolError {
313            code: ToolErrorCode::PermissionDenied.code(),
314            message: format!(
315                "Permission denied: {:?} not allowed in {} mode",
316                operation,
317                mode.display_name()
318            ),
319        })
320    }
321
322    /// Mark a file as read (for edit validation)
323    pub fn mark_as_read(&self, path: &Path) {
324        // Canonicalize to handle symlinks (e.g., /var → /private/var on macOS)
325        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
326        self.read_files.write().insert(canonical);
327    }
328
329    /// Check if a file has been read (required before edit)
330    pub fn was_read(&self, path: &Path) -> bool {
331        // Canonicalize for consistent comparison
332        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
333        self.read_files.read().contains(&canonical)
334    }
335
336    /// Validate that file was read before edit
337    pub fn validate_read_before_edit(&self, path: &Path) -> Result<(), NikaError> {
338        if !self.was_read(path) {
339            return Err(NikaError::ToolError {
340                code: ToolErrorCode::MustReadFirst.code(),
341                message: format!(
342                    "Must read file before editing: {}. Use the Read tool first.",
343                    path.display()
344                ),
345            });
346        }
347        Ok(())
348    }
349
350    /// Emit a tool event. Logs a warning if the channel is closed/full.
351    pub async fn emit(&self, event: ToolEvent) {
352        if let Some(ref tx) = self.event_tx {
353            if let Err(e) = tx.send(event).await {
354                tracing::debug!(error = %e, "Tool event channel closed, event dropped");
355            }
356        }
357    }
358
359    /// Clear read files tracking (for testing or reset)
360    pub fn clear_read_tracking(&self) {
361        self.read_files.write().clear();
362    }
363}
364
365impl std::fmt::Debug for ToolContext {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("ToolContext")
368            .field("working_dir", &self.working_dir)
369            .field("permission_mode", &self.permission_mode())
370            .field("read_files_count", &self.read_files.read().len())
371            .finish()
372    }
373}
374
375// ═══════════════════════════════════════════════════════════════════════════
376// TEST UTILITIES (SHARED)
377// ═══════════════════════════════════════════════════════════════════════════
378
379/// Shared test utilities for file tools
380///
381/// This module provides common test setup patterns used across all file tools.
382/// Import in other tool test modules via:
383/// ```ignore
384/// use crate::tools::context::testing::{setup_test, create_test_file};
385/// ```
386#[cfg(test)]
387pub mod testing {
388    use super::*;
389    use std::path::PathBuf;
390    use std::sync::Arc;
391    use tempfile::TempDir;
392
393    /// Standard test setup for file tools.
394    ///
395    /// Creates a temporary directory and a ToolContext in YoloMode.
396    /// The TempDir is returned to keep it alive for the duration of the test.
397    ///
398    /// # Example
399    ///
400    /// ```ignore
401    /// use crate::tools::context::testing::setup_test;
402    ///
403    /// #[tokio::test]
404    /// async fn test_my_tool() {
405    ///     let (temp_dir, ctx) = setup_test().await;
406    ///     // Use ctx for tool operations
407    /// }
408    /// ```
409    pub async fn setup_test() -> (TempDir, Arc<ToolContext>) {
410        let temp_dir = TempDir::new().expect("Failed to create temp dir");
411        let ctx = Arc::new(ToolContext::new(
412            temp_dir.path().to_path_buf(),
413            PermissionMode::YoloMode,
414        ));
415        (temp_dir, ctx)
416    }
417
418    /// Create a test file with content in the temp directory.
419    ///
420    /// Returns the absolute path to the created file.
421    ///
422    /// # Example
423    ///
424    /// ```ignore
425    /// let (temp_dir, ctx) = setup_test().await;
426    /// let path = create_test_file(&temp_dir, "test.txt", "Hello World").await;
427    /// ```
428    pub async fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
429        let path = dir.path().join(name);
430        tokio::fs::write(&path, content)
431            .await
432            .expect("Failed to write test file");
433        path
434    }
435
436    /// Create a nested directory structure with test files.
437    ///
438    /// The `files` parameter is a slice of (relative_path, content) tuples.
439    /// Parent directories are created automatically.
440    ///
441    /// # Example
442    ///
443    /// ```ignore
444    /// let (temp_dir, ctx) = setup_test().await;
445    /// create_test_tree(&temp_dir, &[
446    ///     ("src/main.rs", "fn main() {}"),
447    ///     ("src/lib.rs", "pub mod foo;"),
448    ///     ("tests/test.rs", "#[test] fn it_works() {}"),
449    /// ]).await;
450    /// ```
451    pub async fn create_test_tree(dir: &TempDir, files: &[(&str, &str)]) {
452        for (name, content) in files {
453            let path = dir.path().join(name);
454            if let Some(parent) = path.parent() {
455                tokio::fs::create_dir_all(parent)
456                    .await
457                    .expect("Failed to create directories");
458            }
459            tokio::fs::write(&path, content)
460                .await
461                .expect("Failed to write test file");
462        }
463    }
464}
465
466// ═══════════════════════════════════════════════════════════════════════════
467// TESTS
468// ═══════════════════════════════════════════════════════════════════════════
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::env;
474    use std::sync::Arc;
475
476    fn test_context() -> Arc<ToolContext> {
477        let working_dir = env::current_dir().unwrap();
478        Arc::new(ToolContext::new(working_dir, PermissionMode::YoloMode))
479    }
480
481    #[test]
482    fn test_permission_mode_allows() {
483        assert!(!PermissionMode::Deny.allows(ToolOperation::Read));
484        assert!(!PermissionMode::Plan.allows(ToolOperation::Edit));
485        assert!(PermissionMode::AcceptEdits.allows(ToolOperation::Edit));
486        assert!(!PermissionMode::AcceptEdits.allows(ToolOperation::Write));
487        assert!(PermissionMode::YoloMode.allows(ToolOperation::Write));
488    }
489
490    #[test]
491    fn test_validate_path_relative_resolved() {
492        let ctx = test_context();
493        let result = ctx.validate_path("src/main.rs");
494        // Relative paths should be resolved against working_dir, not rejected
495        assert!(
496            result.is_ok(),
497            "relative path should resolve: {:?}",
498            result.err()
499        );
500        let resolved = result.unwrap();
501        assert!(resolved.is_absolute(), "resolved path must be absolute");
502        assert!(resolved.ends_with("src/main.rs"));
503    }
504
505    #[test]
506    fn test_validate_path_within_working_dir() {
507        let ctx = test_context();
508        let working_dir = ctx.working_dir().to_string_lossy();
509        let valid_path = format!("{}/src/main.rs", working_dir);
510
511        let result = ctx.validate_path(&valid_path);
512        assert!(result.is_ok());
513    }
514
515    #[test]
516    fn test_validate_path_outside_working_dir() {
517        let ctx = test_context();
518        let result = ctx.validate_path("/etc/passwd");
519        assert!(result.is_err());
520        assert!(result.unwrap_err().to_string().contains("outside"));
521    }
522
523    #[test]
524    fn test_read_tracking() {
525        let ctx = test_context();
526        let path = PathBuf::from("/test/file.rs");
527
528        assert!(!ctx.was_read(&path));
529        ctx.mark_as_read(&path);
530        assert!(ctx.was_read(&path));
531
532        ctx.clear_read_tracking();
533        assert!(!ctx.was_read(&path));
534    }
535
536    #[test]
537    fn test_validate_read_before_edit() {
538        let ctx = test_context();
539        let path = PathBuf::from("/test/file.rs");
540
541        // Should fail before read
542        let result = ctx.validate_read_before_edit(&path);
543        assert!(result.is_err());
544
545        // Should pass after read
546        ctx.mark_as_read(&path);
547        let result = ctx.validate_read_before_edit(&path);
548        assert!(result.is_ok());
549    }
550
551    #[test]
552    fn test_permission_mode_change() {
553        let ctx = test_context();
554
555        assert_eq!(ctx.permission_mode(), PermissionMode::YoloMode);
556
557        ctx.set_permission_mode(PermissionMode::Plan);
558        assert_eq!(ctx.permission_mode(), PermissionMode::Plan);
559    }
560
561    #[test]
562    fn test_check_permission_deny_mode() {
563        let working_dir = env::current_dir().unwrap();
564        let ctx = ToolContext::new(working_dir, PermissionMode::Deny);
565
566        let result = ctx.check_permission(ToolOperation::Read);
567        assert!(result.is_err());
568    }
569
570    #[test]
571    fn test_check_permission_accept_all() {
572        let ctx = test_context();
573
574        assert!(ctx.check_permission(ToolOperation::Read).is_ok());
575        assert!(ctx.check_permission(ToolOperation::Write).is_ok());
576        assert!(ctx.check_permission(ToolOperation::Edit).is_ok());
577        assert!(ctx.check_permission(ToolOperation::Search).is_ok());
578    }
579}