//! Integration tests for the permission system (TOOLS-PERM-01 through TOOLS-PERM-04). //! //! This test suite validates: //! - Permission modes (ask, whitelist, yolo) //! - Decision caching with TTL and scope //! - Whitelist pattern matching //! - ACP integration (stubbed for now) //! - Integration with file operations use dirigent_tools::config::{ DecisionScope, PermissionConfig, PermissionMode, SandboxConfig, WhitelistConfig, }; use dirigent_tools::error::ToolError; use dirigent_tools::fs::write::{write_text_file, WriteTextFileRequest}; use dirigent_tools::permission::check::{check_permission, PermissionContext}; use dirigent_tools::permission::cache::{CacheKey, PermissionDecision}; use dirigent_tools::permission::whitelist::{CompiledWhitelist, PermissionOperation}; use std::time::Duration; use tempfile::TempDir; /// Create a test sandbox configuration. fn create_test_sandbox(temp_dir: &TempDir) -> SandboxConfig { let mut config = SandboxConfig { allowed_roots: vec![temp_dir.path().to_path_buf()], blocked_paths: vec![], allow_symlink_escape: false, follow_symlinks_within_roots: true, read_enabled: true, write_enabled: true, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, eol_policy: dirigent_tools::config::EolPolicy::Preserve, encoding: "utf-8".to_string(), }; config.normalize_roots(); config } #[tokio::test] async fn test_yolo_mode_allows_everything() { let temp_dir = TempDir::new().unwrap(); let config = PermissionConfig { mode: PermissionMode::Yolo, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerConnector, whitelist: WhitelistConfig::default(), }; let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap(); let context = PermissionContext::new( "test-connector".to_string(), Some("test-session".to_string()), whitelist, ); // All operations should be allowed in yolo mode let operations = vec![ PermissionOperation::Read { path: temp_dir.path().join("file.txt").display().to_string(), }, PermissionOperation::Write { path: temp_dir.path().join("file.txt").display().to_string(), }, PermissionOperation::Execute { command: "dangerous_command".to_string(), cwd: temp_dir.path().display().to_string(), }, ]; for operation in operations { let decision = check_permission(&operation, &context, &config).await.unwrap(); assert_eq!(decision, PermissionDecision::Allowed); } } #[tokio::test] async fn test_whitelist_mode_auto_approves_matching() { let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().display().to_string(); let config = PermissionConfig { mode: PermissionMode::Whitelist, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerConnector, whitelist: WhitelistConfig { write_paths: vec![format!("{}/**", temp_path)], execute_commands: vec!["cargo".to_string(), "npm".to_string()], }, }; let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap(); let context = PermissionContext::new( "test-connector".to_string(), None, whitelist, ); // Read should always be allowed let read_op = PermissionOperation::Read { path: "/any/path/file.txt".to_string(), }; let decision = check_permission(&read_op, &context, &config).await.unwrap(); assert_eq!(decision, PermissionDecision::Allowed); // Write within whitelist should be allowed let write_ok = PermissionOperation::Write { path: temp_dir.path().join("file.txt").display().to_string(), }; let decision = check_permission(&write_ok, &context, &config).await.unwrap(); assert_eq!(decision, PermissionDecision::Allowed); // Execute whitelisted command should be allowed let exec_ok = PermissionOperation::Execute { command: "cargo".to_string(), cwd: temp_dir.path().display().to_string(), }; let decision = check_permission(&exec_ok, &context, &config).await.unwrap(); assert_eq!(decision, PermissionDecision::Allowed); } #[tokio::test] async fn test_decision_cache_per_connector() { // Note: This test manually adds cache entries to verify cache separation. // In production, cache entries are only added when users select "always" options in ACP prompts. let config = PermissionConfig { mode: PermissionMode::Ask, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerConnector, whitelist: WhitelistConfig::default(), }; let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap(); let context1 = PermissionContext::new( "connector-1".to_string(), Some("session-1".to_string()), whitelist.clone(), ); let context2 = PermissionContext::new( "connector-2".to_string(), Some("session-2".to_string()), whitelist, ); // Manually add cache entries to test separation { let mut cache1 = context1.cache.lock().unwrap(); let key = CacheKey::write("/test/path", "connector-1", Some("session-1"), DecisionScope::PerConnector); cache1.insert(key, PermissionDecision::Allowed, Duration::from_secs(300)); } assert_eq!(context1.cache_size(), 1); // Second connector should have separate cache assert_eq!(context2.cache_size(), 0); { let mut cache2 = context2.cache.lock().unwrap(); let key = CacheKey::write("/test/path", "connector-2", Some("session-2"), DecisionScope::PerConnector); cache2.insert(key, PermissionDecision::Allowed, Duration::from_secs(300)); } assert_eq!(context2.cache_size(), 1); } #[tokio::test] async fn test_decision_cache_per_session() { // Note: This test manually adds cache entries to verify per-session scoping. // In production, cache entries are only added when users select "always" options in ACP prompts. let config = PermissionConfig { mode: PermissionMode::Ask, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerSession, whitelist: WhitelistConfig::default(), }; let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap(); // Same connector, different sessions - they share the same cache object but have different keys let context1 = PermissionContext::new( "connector-1".to_string(), Some("session-1".to_string()), whitelist.clone(), ); let context2 = PermissionContext::new( "connector-1".to_string(), Some("session-2".to_string()), whitelist, ); // Manually add entries for each session with per-session scope { let mut cache1 = context1.cache.lock().unwrap(); let key1 = CacheKey::write("/test/path", "connector-1", Some("session-1"), DecisionScope::PerSession); cache1.insert(key1, PermissionDecision::Allowed, Duration::from_secs(300)); } { let mut cache2 = context2.cache.lock().unwrap(); let key2 = CacheKey::write("/test/path", "connector-1", Some("session-2"), DecisionScope::PerSession); cache2.insert(key2, PermissionDecision::Allowed, Duration::from_secs(300)); } // Each context has its own cache object, so they each have 1 entry assert_eq!(context1.cache_size(), 1); assert_eq!(context2.cache_size(), 1); } #[tokio::test] async fn test_cache_ttl_expiration() { // Note: This test manually adds a cache entry with short TTL to test expiration. let whitelist = CompiledWhitelist::compile(&WhitelistConfig::default()).unwrap(); let context = PermissionContext::new( "test-connector".to_string(), None, whitelist, ); // Manually add entry with very short TTL { let mut cache = context.cache.lock().unwrap(); let key = CacheKey::write("/test/path", "test-connector", None, DecisionScope::PerConnector); cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_millis(1)); } // Cache entry should be present let initial_size = context.cache_size(); assert_eq!(initial_size, 1); // Wait for expiration tokio::time::sleep(Duration::from_millis(10)).await; // Try to get the entry (should be expired and removed) { let mut cache = context.cache.lock().unwrap(); let key = CacheKey::write("/test/path", "test-connector", None, DecisionScope::PerConnector); let result = cache.get(&key); assert_eq!(result, None); // Should be expired } // Cache should now be empty assert_eq!(context.cache_size(), 0); } #[tokio::test] async fn test_write_file_with_permission_yolo() { let temp_dir = TempDir::new().unwrap(); let sandbox_config = create_test_sandbox(&temp_dir); let permission_config = PermissionConfig { mode: PermissionMode::Yolo, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerConnector, whitelist: WhitelistConfig::default(), }; let whitelist = CompiledWhitelist::compile(&permission_config.whitelist).unwrap(); let permission_context = PermissionContext::new( "test-connector".to_string(), None, whitelist, ); let file_path = temp_dir.path().join("test_file.txt"); let request = WriteTextFileRequest { path: file_path.display().to_string(), content: "Hello, world!".to_string(), }; // Should succeed in yolo mode let result = write_text_file( request, &sandbox_config, &permission_config, &permission_context, ) .await; assert!(result.is_ok()); assert!(file_path.exists()); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "Hello, world!"); } #[tokio::test] async fn test_write_file_with_permission_whitelist() { let temp_dir = TempDir::new().unwrap(); let sandbox_config = create_test_sandbox(&temp_dir); let temp_path = temp_dir.path().display().to_string(); let permission_config = PermissionConfig { mode: PermissionMode::Whitelist, remember_decisions: true, remember_ttl_secs: 300, scope: DecisionScope::PerConnector, whitelist: WhitelistConfig { write_paths: vec![format!("{}/**", temp_path)], execute_commands: vec![], }, }; let whitelist = CompiledWhitelist::compile(&permission_config.whitelist).unwrap(); let permission_context = PermissionContext::new( "test-connector".to_string(), None, whitelist, ); let file_path = temp_dir.path().join("test_file.txt"); let request = WriteTextFileRequest { path: file_path.display().to_string(), content: "Whitelisted write".to_string(), }; // Should succeed because path matches whitelist let result = write_text_file( request, &sandbox_config, &permission_config, &permission_context, ) .await; assert!(result.is_ok()); assert!(file_path.exists()); let content = std::fs::read_to_string(&file_path).unwrap(); assert_eq!(content, "Whitelisted write"); } #[tokio::test] async fn test_cache_key_equality() { // Test that cache keys are correctly generated for different scopes let key1 = CacheKey::write( "/path/to/file", "conn-1", Some("session-1"), DecisionScope::PerConnector, ); let key2 = CacheKey::write( "/path/to/file", "conn-1", Some("session-1"), DecisionScope::PerConnector, ); let key3 = CacheKey::write( "/path/to/file", "conn-1", Some("session-2"), DecisionScope::PerConnector, ); // Same connector and path should produce equal keys for per-connector scope assert_eq!(key1, key2); // Different sessions should still be equal for per-connector scope assert_eq!(key1, key3); // Different scope should produce different keys let key4 = CacheKey::write( "/path/to/file", "conn-1", Some("session-1"), DecisionScope::PerSession, ); assert_ne!(key1, key4); } #[test] fn test_whitelist_compilation() { let config = WhitelistConfig { write_paths: vec![ "C:/work/**".to_string(), "/home/user/**".to_string(), ], execute_commands: vec![ "cargo".to_string(), "npm*".to_string(), ], }; let whitelist = CompiledWhitelist::compile(&config); assert!(whitelist.is_ok()); let whitelist = whitelist.unwrap(); assert!(whitelist.has_write_patterns()); assert!(whitelist.has_execute_patterns()); } #[test] fn test_invalid_whitelist_pattern() { let config = WhitelistConfig { write_paths: vec!["[invalid".to_string()], // Unclosed bracket execute_commands: vec![], }; let result = CompiledWhitelist::compile(&config); assert!(result.is_err()); match result { Err(ToolError::InvalidConfig(_)) => { // Expected error } _ => panic!("Expected InvalidConfig error"), } } #[tokio::test] async fn test_permission_context_sharing() { let whitelist = CompiledWhitelist::compile(&WhitelistConfig::default()).unwrap(); let context = PermissionContext::new( "test-connector".to_string(), None, whitelist, ); // Clone should share the same cache let context_clone = context.clone(); // Add entry via original context { let mut cache = context.cache.lock().unwrap(); let key = CacheKey::write("/path", "test", None, DecisionScope::PerConnector); cache.insert(key, PermissionDecision::Allowed, Duration::from_secs(300)); } // Clone should see the same entry assert_eq!(context.cache_size(), 1); assert_eq!(context_clone.cache_size(), 1); }