Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions crates/libs/clawdstrike/src/async_guards/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use dashmap::DashMap;
Expand All @@ -14,6 +15,8 @@ use crate::async_guards::types::{
use crate::guards::{GuardAction, GuardContext, GuardResult, Severity};
use crate::policy::{AsyncExecutionMode, TimeoutBehavior};

const DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT: usize = 64;

#[derive(Clone, Debug)]
pub enum OwnedGuardAction {
FileAccess {
Expand Down Expand Up @@ -94,6 +97,11 @@ pub struct AsyncGuardRuntime {
caches: DashMap<String, Arc<TtlCache>>,
limiters: DashMap<String, Arc<TokenBucket>>,
breakers: DashMap<String, Arc<CircuitBreaker>>,
background_slots: Arc<tokio::sync::Semaphore>,
background_in_flight_limit: usize,
background_running: AtomicUsize,
background_peak_running: AtomicUsize,
background_dropped: AtomicUsize,
}

impl Default for AsyncGuardRuntime {
Expand All @@ -104,18 +112,44 @@ impl Default for AsyncGuardRuntime {

impl AsyncGuardRuntime {
pub fn new() -> Self {
Self::with_background_in_flight_limit(DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT)
}

pub fn with_background_in_flight_limit(limit: usize) -> Self {
let limit = limit.max(1);
Self {
http: HttpClient::new(),
caches: DashMap::new(),
limiters: DashMap::new(),
breakers: DashMap::new(),
background_slots: Arc::new(tokio::sync::Semaphore::new(limit)),
background_in_flight_limit: limit,
background_running: AtomicUsize::new(0),
background_peak_running: AtomicUsize::new(0),
background_dropped: AtomicUsize::new(0),
}
}

pub fn http(&self) -> &HttpClient {
&self.http
}

pub fn background_inflight_limit(&self) -> usize {
self.background_in_flight_limit
}

pub fn background_inflight_count(&self) -> usize {
self.background_running.load(Ordering::Relaxed)
}

pub fn background_peak_inflight(&self) -> usize {
self.background_peak_running.load(Ordering::Relaxed)
}

pub fn background_dropped_count(&self) -> usize {
self.background_dropped.load(Ordering::Relaxed)
}

pub async fn evaluate_async_guards(
self: &Arc<Self>,
guards: &[Arc<dyn AsyncGuard>],
Expand Down Expand Up @@ -210,13 +244,28 @@ impl AsyncGuardRuntime {

background.sort_by_key(|(idx, _)| *idx);
for (_idx, g) in background {
self.spawn_background(g.clone(), owned_action.clone(), ctx.clone());
out.push(
GuardResult::allow(g.name()).with_details(serde_json::json!({
"background": true,
"note": "scheduled"
})),
);
if self.spawn_background(g.clone(), owned_action.clone(), ctx.clone()) {
out.push(
GuardResult::allow(g.name()).with_details(serde_json::json!({
"background": true,
"note": "scheduled",
"in_flight_limit": self.background_inflight_limit()
})),
);
} else {
out.push(
GuardResult::warn(
g.name(),
"background guard dropped due to in-flight limit",
)
.with_details(serde_json::json!({
"background": true,
"note": "dropped",
"in_flight_limit": self.background_inflight_limit(),
"dropped_total": self.background_dropped_count()
})),
);
}
}
}

Expand All @@ -228,9 +277,20 @@ impl AsyncGuardRuntime {
guard: Arc<dyn AsyncGuard>,
action: OwnedGuardAction,
context: GuardContext,
) {
) -> bool {
let permit = match self.background_slots.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
self.background_dropped.fetch_add(1, Ordering::Relaxed);
return false;
}
};

let runtime = Arc::clone(self);
tokio::spawn(async move {
let running = runtime.background_running.fetch_add(1, Ordering::Relaxed) + 1;
update_max(&runtime.background_peak_running, running);

let borrowed = action.as_guard_action();
let result = runtime
.evaluate_one(guard.clone(), &borrowed, &context)
Expand All @@ -245,7 +305,11 @@ impl AsyncGuardRuntime {
"background async guard would have denied"
);
}

runtime.background_running.fetch_sub(1, Ordering::Relaxed);
drop(permit);
});
true
}

async fn evaluate_one(
Expand Down Expand Up @@ -390,6 +454,21 @@ impl AsyncGuardRuntime {
}
}

fn update_max(target: &AtomicUsize, candidate: usize) {
loop {
let current = target.load(Ordering::Relaxed);
if candidate <= current {
return;
}
if target
.compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
return;
}
}
}

fn fallback(
guard: &str,
cfg: &AsyncGuardConfig,
Expand Down
50 changes: 46 additions & 4 deletions crates/libs/clawdstrike/src/guards/forbidden_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use async_trait::async_trait;
use glob::Pattern;
use serde::{Deserialize, Serialize};

use super::path_normalization::normalize_path_for_policy;
use super::path_normalization::{normalize_path_for_policy, normalize_path_for_policy_with_fs};
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};

/// Configuration for ForbiddenPathGuard
Expand Down Expand Up @@ -189,18 +189,28 @@ impl ForbiddenPathGuard {

/// Check if a path is forbidden
pub fn is_forbidden(&self, path: &str) -> bool {
let path = normalize_path_for_policy(path);
let lexical_path = normalize_path_for_policy(path);
let resolved_path = normalize_path_for_policy_with_fs(path);
let resolved_differs = resolved_path != lexical_path;

// Check exceptions first
for exception in &self.exceptions {
if exception.matches(&path) {
let exception_matches = if resolved_differs {
// If resolution changed the path (e.g., via symlink/canonical host mount aliases),
// require the exception to match the resolved target to avoid lexical bypasses.
exception.matches(&resolved_path)
} else {
exception.matches(&lexical_path)
};

if exception_matches {
return false;
}
}

// Check forbidden patterns
for pattern in &self.patterns {
if pattern.matches(&path) {
if pattern.matches(&resolved_path) || pattern.matches(&lexical_path) {
return true;
}
}
Expand Down Expand Up @@ -356,4 +366,36 @@ remove_patterns:
.await;
assert!(result.allowed);
}

#[cfg(unix)]
#[test]
fn symlink_target_matching_forbidden_pattern_is_forbidden() {
use std::os::unix::fs::symlink;

let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4()));
let safe_dir = root.join("safe");
let forbidden_dir = root.join("forbidden");
std::fs::create_dir_all(&safe_dir).expect("create safe dir");
std::fs::create_dir_all(&forbidden_dir).expect("create forbidden dir");

let target = forbidden_dir.join("secret.txt");
std::fs::write(&target, "secret").expect("write target");
let link = safe_dir.join("link.txt");
symlink(&target, &link).expect("create symlink");

let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig {
enabled: true,
patterns: Some(vec!["**/forbidden/**".to_string()]),
exceptions: vec![],
additional_patterns: vec![],
remove_patterns: vec![],
});

assert!(
guard.is_forbidden(link.to_str().expect("utf-8 path")),
"symlink target that resolves into forbidden path must be blocked"
);

let _ = std::fs::remove_dir_all(&root);
}
}
39 changes: 35 additions & 4 deletions crates/libs/clawdstrike/src/guards/path_allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use async_trait::async_trait;
use glob::Pattern;
use serde::{Deserialize, Serialize};

use super::path_normalization::normalize_path_for_policy;
use super::path_normalization::normalize_path_for_policy_with_fs;
use super::{Guard, GuardAction, GuardContext, GuardResult, Severity};

/// Configuration for `PathAllowlistGuard`.
Expand Down Expand Up @@ -99,23 +99,23 @@ impl PathAllowlistGuard {
if !self.enabled {
return true;
}
let normalized = normalize_path_for_policy(path);
let normalized = normalize_path_for_policy_with_fs(path);
Self::matches_any(&self.file_access_allow, &normalized)
}

pub fn is_file_write_allowed(&self, path: &str) -> bool {
if !self.enabled {
return true;
}
let normalized = normalize_path_for_policy(path);
let normalized = normalize_path_for_policy_with_fs(path);
Self::matches_any(&self.file_write_allow, &normalized)
}

pub fn is_patch_allowed(&self, path: &str) -> bool {
if !self.enabled {
return true;
}
let normalized = normalize_path_for_policy(path);
let normalized = normalize_path_for_policy_with_fs(path);
Self::matches_any(&self.patch_allow, &normalized)
}
}
Expand Down Expand Up @@ -221,4 +221,35 @@ mod tests {
assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs"));
assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs"));
}

#[cfg(unix)]
#[test]
fn symlink_escape_outside_allowlist_is_denied() {
use std::os::unix::fs::symlink;

let root = std::env::temp_dir().join(format!("path-allowlist-{}", uuid::Uuid::new_v4()));
let allowed_dir = root.join("allowed");
let outside_dir = root.join("outside");
std::fs::create_dir_all(&allowed_dir).expect("create allowed dir");
std::fs::create_dir_all(&outside_dir).expect("create outside dir");

let target = outside_dir.join("secret.txt");
std::fs::write(&target, "sensitive").expect("write target");
let link = allowed_dir.join("link.txt");
symlink(&target, &link).expect("create symlink");

let guard = PathAllowlistGuard::with_config(PathAllowlistConfig {
enabled: true,
file_access_allow: vec![format!("{}/allowed/**", root.display())],
file_write_allow: vec![format!("{}/allowed/**", root.display())],
patch_allow: vec![],
});

assert!(
!guard.is_file_access_allowed(link.to_str().expect("utf-8 path")),
"symlink target outside allowlist must be denied"
);

let _ = std::fs::remove_dir_all(&root);
}
}
42 changes: 41 additions & 1 deletion crates/libs/clawdstrike/src/guards/path_normalization.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Shared path normalization for policy path matching.

use std::path::Path;

/// Normalize a path for policy glob matching.
///
/// Rules:
Expand Down Expand Up @@ -46,9 +48,33 @@ pub fn normalize_path_for_policy(path: &str) -> String {
}
}

/// Normalize a path for policy matching, preferring filesystem-resolved targets when possible.
///
/// - For existing paths, this resolves symlinks via `canonicalize`.
/// - For non-existing write targets, this resolves the parent directory and rejoins the filename.
/// - Falls back to lexical normalization when resolution is not possible.
pub fn normalize_path_for_policy_with_fs(path: &str) -> String {
resolve_path_for_policy(path).unwrap_or_else(|| normalize_path_for_policy(path))
}

fn resolve_path_for_policy(path: &str) -> Option<String> {
let raw = Path::new(path);
if let Ok(canonical) = std::fs::canonicalize(raw) {
return Some(normalize_path_for_policy(&canonical.to_string_lossy()));
}

let parent = raw.parent()?;
let canonical_parent = std::fs::canonicalize(parent).ok()?;
let candidate = match raw.file_name() {
Some(name) => canonical_parent.join(name),
None => canonical_parent,
};
Some(normalize_path_for_policy(&candidate.to_string_lossy()))
}

#[cfg(test)]
mod tests {
use super::normalize_path_for_policy;
use super::{normalize_path_for_policy, normalize_path_for_policy_with_fs};

#[test]
fn normalizes_separators_and_dots() {
Expand All @@ -68,4 +94,18 @@ mod tests {
assert_eq!(normalize_path_for_policy("a/b/../../c"), "c");
assert_eq!(normalize_path_for_policy("../a/../b"), "../b");
}

#[test]
fn fs_aware_normalization_uses_canonical_parent_for_new_file() {
let root =
std::env::temp_dir().join(format!("path-normalization-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).expect("create root");
let candidate = root.join("new_file.txt");
let normalized = normalize_path_for_policy_with_fs(candidate.to_str().expect("utf-8 path"));
assert!(
normalized.ends_with("/new_file.txt"),
"normalized path should preserve file name, got {normalized}"
);
let _ = std::fs::remove_dir_all(&root);
}
}
Loading