Files
2026-05-08 01:59:04 +02:00

438 lines
14 KiB
Rust

//! 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);
}