438 lines
14 KiB
Rust
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);
|
|
}
|