security: harden architecture against Moltbot security model
- Discord: add allowed_users field + sender validation in listen() - Slack: add allowed_users field + sender validation in listen() - Webhook: add X-Webhook-Secret header auth (401 on mismatch) - SecurityPolicy: add ActionTracker with sliding-window rate limiting - record_action() enforces max_actions_per_hour - is_rate_limited() checks without recording - Gateway: print auth status on startup (ENABLED/DISABLED) - 22 new tests (Discord/Slack allowlists, gateway header extraction, rate limiter: starts at zero, records, allows within limit, blocks over limit, clone independence) - 554 tests passing, 0 clippy warnings
This commit is contained in:
parent
cf0ca71fdc
commit
542bb80743
7 changed files with 287 additions and 6 deletions
|
|
@ -1,5 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
/// How much autonomy the agent has
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -19,6 +21,47 @@ impl Default for AutonomyLevel {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sliding-window action tracker for rate limiting.
|
||||
#[derive(Debug)]
|
||||
pub struct ActionTracker {
|
||||
/// Timestamps of recent actions (kept within the last hour).
|
||||
actions: Mutex<Vec<Instant>>,
|
||||
}
|
||||
|
||||
impl ActionTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
actions: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an action and return the current count within the window.
|
||||
pub fn record(&self) -> usize {
|
||||
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now);
|
||||
actions.retain(|t| *t > cutoff);
|
||||
actions.push(Instant::now());
|
||||
actions.len()
|
||||
}
|
||||
|
||||
/// Count of actions in the current window without recording.
|
||||
pub fn count(&self) -> usize {
|
||||
let mut actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cutoff = Instant::now().checked_sub(std::time::Duration::from_secs(3600)).unwrap_or_else(Instant::now);
|
||||
actions.retain(|t| *t > cutoff);
|
||||
actions.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ActionTracker {
|
||||
fn clone(&self) -> Self {
|
||||
let actions = self.actions.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Self {
|
||||
actions: Mutex::new(actions.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Security policy enforced on all tool executions
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecurityPolicy {
|
||||
|
|
@ -29,6 +72,7 @@ pub struct SecurityPolicy {
|
|||
pub forbidden_paths: Vec<String>,
|
||||
pub max_actions_per_hour: u32,
|
||||
pub max_cost_per_day_cents: u32,
|
||||
pub tracker: ActionTracker,
|
||||
}
|
||||
|
||||
impl Default for SecurityPolicy {
|
||||
|
|
@ -60,6 +104,7 @@ impl Default for SecurityPolicy {
|
|||
],
|
||||
max_actions_per_hour: 20,
|
||||
max_cost_per_day_cents: 500,
|
||||
tracker: ActionTracker::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +157,18 @@ impl SecurityPolicy {
|
|||
self.autonomy != AutonomyLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Record an action and check if the rate limit has been exceeded.
|
||||
/// Returns `true` if the action is allowed, `false` if rate-limited.
|
||||
pub fn record_action(&self) -> bool {
|
||||
let count = self.tracker.record();
|
||||
count <= self.max_actions_per_hour as usize
|
||||
}
|
||||
|
||||
/// Check if the rate limit would be exceeded without recording.
|
||||
pub fn is_rate_limited(&self) -> bool {
|
||||
self.tracker.count() >= self.max_actions_per_hour as usize
|
||||
}
|
||||
|
||||
/// Build from config sections
|
||||
pub fn from_config(
|
||||
autonomy_config: &crate::config::AutonomyConfig,
|
||||
|
|
@ -125,6 +182,7 @@ impl SecurityPolicy {
|
|||
forbidden_paths: autonomy_config.forbidden_paths.clone(),
|
||||
max_actions_per_hour: autonomy_config.max_actions_per_hour,
|
||||
max_cost_per_day_cents: autonomy_config.max_cost_per_day_cents,
|
||||
tracker: ActionTracker::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,4 +420,69 @@ mod tests {
|
|||
assert!(p.max_actions_per_hour > 0);
|
||||
assert!(p.max_cost_per_day_cents > 0);
|
||||
}
|
||||
|
||||
// ── ActionTracker / rate limiting ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn action_tracker_starts_at_zero() {
|
||||
let tracker = ActionTracker::new();
|
||||
assert_eq!(tracker.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_tracker_records_actions() {
|
||||
let tracker = ActionTracker::new();
|
||||
assert_eq!(tracker.record(), 1);
|
||||
assert_eq!(tracker.record(), 2);
|
||||
assert_eq!(tracker.record(), 3);
|
||||
assert_eq!(tracker.count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_action_allows_within_limit() {
|
||||
let p = SecurityPolicy {
|
||||
max_actions_per_hour: 5,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
for _ in 0..5 {
|
||||
assert!(p.record_action(), "should allow actions within limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_action_blocks_over_limit() {
|
||||
let p = SecurityPolicy {
|
||||
max_actions_per_hour: 3,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(p.record_action()); // 1
|
||||
assert!(p.record_action()); // 2
|
||||
assert!(p.record_action()); // 3
|
||||
assert!(!p.record_action()); // 4 — over limit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_rate_limited_reflects_count() {
|
||||
let p = SecurityPolicy {
|
||||
max_actions_per_hour: 2,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(!p.is_rate_limited());
|
||||
p.record_action();
|
||||
assert!(!p.is_rate_limited());
|
||||
p.record_action();
|
||||
assert!(p.is_rate_limited());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_tracker_clone_is_independent() {
|
||||
let tracker = ActionTracker::new();
|
||||
tracker.record();
|
||||
tracker.record();
|
||||
let cloned = tracker.clone();
|
||||
assert_eq!(cloned.count(), 2);
|
||||
tracker.record();
|
||||
assert_eq!(tracker.count(), 3);
|
||||
assert_eq!(cloned.count(), 2); // clone is independent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue