clawdstrike 0.2.5

Security guards and policy engine for AI agent execution
Documentation
#![cfg(feature = "full")]
#![allow(clippy::expect_used, clippy::unwrap_used)]

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use clawdstrike::async_guards::{
    AsyncGuard, AsyncGuardConfig, AsyncGuardError, AsyncGuardRuntime, CircuitBreakerConfig,
    RateLimitConfig,
};
use clawdstrike::guards::{GuardAction, GuardContext, GuardResult};
use clawdstrike::policy::{AsyncExecutionMode, TimeoutBehavior};
use clawdstrike::Severity;

struct SleepGuard {
    name: &'static str,
    cfg: AsyncGuardConfig,
    calls: Arc<AtomicUsize>,
    sleep: Duration,
}

#[async_trait]
impl AsyncGuard for SleepGuard {
    fn name(&self) -> &str {
        self.name
    }

    fn handles(&self, action: &GuardAction<'_>) -> bool {
        matches!(action, GuardAction::FileAccess(_))
    }

    fn config(&self) -> &AsyncGuardConfig {
        &self.cfg
    }

    fn cache_key(&self, _action: &GuardAction<'_>, _context: &GuardContext) -> Option<String> {
        Some("k".to_string())
    }

    async fn check_uncached(
        &self,
        _action: &GuardAction<'_>,
        _context: &GuardContext,
        _http: &clawdstrike::async_guards::http::HttpClient,
    ) -> std::result::Result<GuardResult, AsyncGuardError> {
        self.calls.fetch_add(1, Ordering::Relaxed);
        tokio::time::sleep(self.sleep).await;
        Ok(GuardResult::allow(self.name()))
    }
}

struct ResultGuard {
    name: &'static str,
    cfg: AsyncGuardConfig,
    calls: Arc<AtomicUsize>,
    allowed: bool,
}

#[async_trait]
impl AsyncGuard for ResultGuard {
    fn name(&self) -> &str {
        self.name
    }

    fn handles(&self, action: &GuardAction<'_>) -> bool {
        matches!(action, GuardAction::FileAccess(_))
    }

    fn config(&self) -> &AsyncGuardConfig {
        &self.cfg
    }

    fn cache_key(&self, _action: &GuardAction<'_>, _context: &GuardContext) -> Option<String> {
        None
    }

    async fn check_uncached(
        &self,
        _action: &GuardAction<'_>,
        _context: &GuardContext,
        _http: &clawdstrike::async_guards::http::HttpClient,
    ) -> std::result::Result<GuardResult, AsyncGuardError> {
        self.calls.fetch_add(1, Ordering::Relaxed);
        Ok(if self.allowed {
            GuardResult::allow(self.name())
        } else {
            GuardResult::block(self.name(), Severity::Error, "denied")
        })
    }
}

fn base_async_cfg() -> AsyncGuardConfig {
    AsyncGuardConfig {
        timeout: Duration::from_millis(10),
        on_timeout: TimeoutBehavior::Warn,
        execution_mode: AsyncExecutionMode::Sequential,
        cache_enabled: false,
        cache_ttl: Duration::from_secs(60),
        cache_max_size_bytes: 1024 * 1024,
        rate_limit: None,
        circuit_breaker: None,
        retry: None,
    }
}

#[tokio::test]
async fn timeout_warns() {
    let calls = Arc::new(AtomicUsize::new(0));
    let guard = Arc::new(SleepGuard {
        name: "sleep",
        cfg: AsyncGuardConfig {
            on_timeout: TimeoutBehavior::Warn,
            ..base_async_cfg()
        },
        calls: calls.clone(),
        sleep: Duration::from_millis(200),
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();
    let results = runtime
        .evaluate_async_guards(&[guard], &GuardAction::FileAccess("/tmp/a"), &ctx, false)
        .await;

    assert_eq!(calls.load(Ordering::Relaxed), 1);
    assert_eq!(results.len(), 1);
    assert!(results[0].allowed);
    assert!(matches!(
        results[0].severity,
        clawdstrike::Severity::Warning
    ));
}

#[tokio::test]
async fn timeout_denies() {
    let calls = Arc::new(AtomicUsize::new(0));
    let guard = Arc::new(SleepGuard {
        name: "sleep",
        cfg: AsyncGuardConfig {
            on_timeout: TimeoutBehavior::Deny,
            ..base_async_cfg()
        },
        calls: calls.clone(),
        sleep: Duration::from_millis(200),
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();
    let results = runtime
        .evaluate_async_guards(&[guard], &GuardAction::FileAccess("/tmp/a"), &ctx, false)
        .await;

    assert_eq!(calls.load(Ordering::Relaxed), 1);
    assert_eq!(results.len(), 1);
    assert!(!results[0].allowed);
}

#[tokio::test]
async fn rate_limit_is_best_effort() {
    let calls = Arc::new(AtomicUsize::new(0));
    let guard = Arc::new(SleepGuard {
        name: "sleep",
        cfg: AsyncGuardConfig {
            rate_limit: Some(RateLimitConfig {
                requests_per_second: 10_000.0,
                burst: 1,
            }),
            ..base_async_cfg()
        },
        calls: calls.clone(),
        sleep: Duration::from_millis(0),
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();
    let _ = runtime
        .evaluate_async_guards(
            &[guard.clone(), guard],
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            false,
        )
        .await;

    assert_eq!(calls.load(Ordering::Relaxed), 2);
}

#[tokio::test]
async fn fail_fast_false_evaluates_all_sequential_guards() {
    let calls_a = Arc::new(AtomicUsize::new(0));
    let calls_b = Arc::new(AtomicUsize::new(0));

    let guard_a = Arc::new(ResultGuard {
        name: "deny_a",
        cfg: base_async_cfg(),
        calls: calls_a.clone(),
        allowed: false,
    });
    let guard_b = Arc::new(ResultGuard {
        name: "deny_b",
        cfg: base_async_cfg(),
        calls: calls_b.clone(),
        allowed: false,
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();
    let results = runtime
        .evaluate_async_guards(
            &[guard_a, guard_b],
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            false,
        )
        .await;

    assert_eq!(calls_a.load(Ordering::Relaxed), 1);
    assert_eq!(calls_b.load(Ordering::Relaxed), 1);
    assert_eq!(results.len(), 2);
    assert!(!results[0].allowed);
    assert!(!results[1].allowed);
}

#[tokio::test]
async fn fail_fast_true_short_circuits_sequential_guards() {
    let calls_a = Arc::new(AtomicUsize::new(0));
    let calls_b = Arc::new(AtomicUsize::new(0));

    let guard_a = Arc::new(ResultGuard {
        name: "deny_a",
        cfg: base_async_cfg(),
        calls: calls_a.clone(),
        allowed: false,
    });
    let guard_b = Arc::new(ResultGuard {
        name: "deny_b",
        cfg: base_async_cfg(),
        calls: calls_b.clone(),
        allowed: false,
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();
    let results = runtime
        .evaluate_async_guards(
            &[guard_a, guard_b],
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            true,
        )
        .await;

    assert_eq!(calls_a.load(Ordering::Relaxed), 1);
    assert_eq!(calls_b.load(Ordering::Relaxed), 0);
    assert_eq!(results.len(), 1);
    assert!(!results[0].allowed);
}

#[tokio::test]
async fn circuit_breaker_opens_on_timeouts() {
    let calls = Arc::new(AtomicUsize::new(0));
    let guard: Arc<dyn AsyncGuard> = Arc::new(SleepGuard {
        name: "sleep",
        cfg: AsyncGuardConfig {
            circuit_breaker: Some(CircuitBreakerConfig {
                failure_threshold: 2,
                reset_timeout: Duration::from_secs(60),
                success_threshold: 1,
            }),
            ..base_async_cfg()
        },
        calls: calls.clone(),
        sleep: Duration::from_millis(200),
    });

    let runtime = Arc::new(AsyncGuardRuntime::new());
    let ctx = GuardContext::new();

    let _ = runtime
        .evaluate_async_guards(
            std::slice::from_ref(&guard),
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            false,
        )
        .await;
    let _ = runtime
        .evaluate_async_guards(
            std::slice::from_ref(&guard),
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            false,
        )
        .await;

    let results = runtime
        .evaluate_async_guards(
            std::slice::from_ref(&guard),
            &GuardAction::FileAccess("/tmp/a"),
            &ctx,
            false,
        )
        .await;

    assert_eq!(calls.load(Ordering::Relaxed), 2);
    assert_eq!(results.len(), 1);
    assert_eq!(
        results[0]
            .details
            .as_ref()
            .and_then(|d| d["async_error"]["kind"].as_str()),
        Some("CircuitOpen")
    );
}

#[tokio::test]
async fn async_background_guards_enforce_inflight_limit() {
    let calls = Arc::new(AtomicUsize::new(0));
    let guard: Arc<dyn AsyncGuard> = Arc::new(SleepGuard {
        name: "background_sleep",
        cfg: AsyncGuardConfig {
            execution_mode: AsyncExecutionMode::Background,
            timeout: Duration::from_secs(1),
            ..base_async_cfg()
        },
        calls: calls.clone(),
        sleep: Duration::from_millis(250),
    });

    let runtime = Arc::new(AsyncGuardRuntime::with_background_in_flight_limit(2));
    let ctx = GuardContext::new();

    for _ in 0..20 {
        let _ = runtime
            .evaluate_async_guards(
                std::slice::from_ref(&guard),
                &GuardAction::FileAccess("/tmp/a"),
                &ctx,
                false,
            )
            .await;
    }

    tokio::time::sleep(Duration::from_millis(100)).await;

    assert!(
        runtime.background_peak_inflight() <= 2,
        "background in-flight peak exceeded configured limit: {}",
        runtime.background_peak_inflight()
    );
    assert!(
        runtime.background_dropped_count() > 0,
        "burst load should drop background tasks once in-flight limit is saturated"
    );

    tokio::time::timeout(Duration::from_secs(2), async {
        while runtime.background_inflight_count() > 0 {
            tokio::time::sleep(Duration::from_millis(20)).await;
        }
    })
    .await
    .expect("background tasks should drain within timeout");
    assert_eq!(runtime.background_inflight_count(), 0);
}