1use hashbrown::HashSet;
12use parking_lot::{Mutex, RwLock};
13use std::future::Future;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use thiserror::Error;
21
22use crate::config::CommandsConfig;
23use crate::config::constants::tools;
24use crate::dotfile_protection::{AccessContext, AccessType, DotfileGuardian, ProtectionDecision, get_global_guardian};
25use crate::tools::apply_patch::{Patch, PatchOperation, decode_apply_patch_input};
26use crate::tools::command_policy::CommandPolicyEvaluator;
27use crate::tools::invocation::ToolInvocationId;
28use crate::tools::rate_limit_config::{tool_calls_per_minute_from_env, tool_calls_per_second_from_env};
29use crate::tools::registry::{RiskLevel, ToolRiskContext, ToolRiskScorer, ToolSource, WorkspaceTrust};
30use crate::tools::tool_intent::{
31 ToolIntent, action_qualified_policy_name, classify_tool_intent, command_session_action_in,
32 command_session_action_is, file_operation_action_is,
33};
34use vtcode_config::constants::tool_limits::{
35 DEFAULT_MAX_TOOL_CALLS_PER_TURN, DEFAULT_SAFETY_MAX_TOOL_CALLS_PER_SESSION, MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN,
36};
37use vtcode_config::core::DotfileProtectionConfig;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum SafetyTrustLevel {
42 Untrusted,
43 #[default]
44 Standard,
45 Elevated,
46 Full,
47}
48
49impl SafetyTrustLevel {
50 #[inline]
51 pub const fn can_bypass_approval(self) -> bool {
52 matches!(self, Self::Elevated | Self::Full)
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct SafetyContext {
59 pub session_id: String,
60 pub trust_level: SafetyTrustLevel,
61}
62
63impl SafetyContext {
64 #[must_use]
65 pub fn new(session_id: impl Into<String>) -> Self {
66 Self {
67 session_id: session_id.into(),
68 trust_level: SafetyTrustLevel::default(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub enum SafetyDecision {
76 Allow,
78 Deny(String),
80 NeedsApproval(String),
82}
83
84impl SafetyDecision {
85 #[inline]
87 pub fn is_allowed(&self) -> bool {
88 matches!(self, SafetyDecision::Allow)
89 }
90
91 #[inline]
93 pub fn is_denied(&self) -> bool {
94 matches!(self, SafetyDecision::Deny(_))
95 }
96
97 #[inline]
99 pub fn needs_approval(&self) -> bool {
100 matches!(self, SafetyDecision::NeedsApproval(_))
101 }
102
103 pub fn reason(&self) -> Option<&str> {
105 match self {
106 SafetyDecision::Allow => None,
107 SafetyDecision::Deny(reason) | SafetyDecision::NeedsApproval(reason) => Some(reason),
108 }
109 }
110}
111
112#[derive(Debug, Error, Clone)]
114pub enum SafetyError {
115 #[error("Rate limit exceeded: {current} calls in {window} (max: {max})")]
116 RateLimitExceeded {
117 current: usize,
118 max: usize,
119 window: &'static str,
120 },
121 #[error("Per-turn tool limit reached (max: {max})")]
122 TurnLimitReached { max: usize },
123 #[error("Session tool limit reached (max: {max})")]
124 SessionLimitReached { max: usize },
125 #[error("Planning workflow violation: {0}")]
126 PlanningPolicyViolation(String),
127 #[error("Command policy denied: {0}")]
128 CommandPolicyDenied(String),
129 #[error("Dotfile protection violation: {0}")]
130 DotfileProtectionViolation(String),
131}
132
133#[derive(Debug, Clone)]
135pub struct SafetyCheckResult {
136 pub decision: SafetyDecision,
138 pub retry_after: Option<Duration>,
140 pub violation: Option<SafetyError>,
142}
143
144#[derive(Debug, Clone)]
146pub struct SafetyGatewayConfig {
147 pub max_per_turn: usize,
149 pub max_per_session: usize,
151 pub rate_limit_per_second: usize,
153 pub rate_limit_per_minute: Option<usize>,
155 pub planning_active: bool,
157 pub workspace_trust: WorkspaceTrust,
159 pub approval_risk_threshold: RiskLevel,
161 pub enforce_rate_limits: bool,
164}
165
166impl Default for SafetyGatewayConfig {
167 fn default() -> Self {
168 let rate_limit_per_second = tool_calls_per_second_from_env().unwrap_or(5);
169
170 Self {
171 max_per_turn: DEFAULT_MAX_TOOL_CALLS_PER_TURN,
172 max_per_session: DEFAULT_SAFETY_MAX_TOOL_CALLS_PER_SESSION,
173 rate_limit_per_second,
174 rate_limit_per_minute: tool_calls_per_minute_from_env(),
175 planning_active: false,
176 workspace_trust: WorkspaceTrust::Trusted,
177 approval_risk_threshold: RiskLevel::Medium,
178 enforce_rate_limits: true,
179 }
180 }
181}
182
183#[derive(Debug, Default)]
185struct RateLimiterState {
186 calls_per_second: std::collections::VecDeque<Instant>,
187 calls_per_minute: std::collections::VecDeque<Instant>,
188 current_turn_count: usize,
189 session_count: usize,
190 control_plane_turn_count: usize,
195}
196
197pub struct SafetyGateway {
203 config: RwLock<SafetyGatewayConfig>,
205 command_policy: Option<CommandPolicyEvaluator>,
207 rate_state: Mutex<RateLimiterState>,
209 preapproved: Mutex<HashSet<String>>,
211 dotfile_guardian: Option<Arc<DotfileGuardian>>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216struct FileAccessTarget {
217 path: PathBuf,
218 access_type: AccessType,
219}
220
221fn primary_path_arg(args: &Value) -> Option<&str> {
222 args.get("path")
223 .and_then(|value| value.as_str())
224 .or_else(|| args.get("file_path").and_then(|value| value.as_str()))
225 .or_else(|| args.get("filepath").and_then(|value| value.as_str()))
226 .or_else(|| args.get("target_path").and_then(|value| value.as_str()))
227}
228
229fn destination_path_arg(args: &Value) -> Option<&str> {
230 args.get("destination").and_then(|value| value.as_str())
231}
232
233fn push_file_access_target(targets: &mut Vec<FileAccessTarget>, path: &str, access_type: AccessType) {
234 let path_str = path.trim();
235 if path_str.is_empty() {
236 return;
237 }
238
239 let path = PathBuf::from(path_str);
240 if targets
243 .iter()
244 .any(|existing| existing.path == path && existing.access_type == access_type)
245 {
246 return;
247 }
248
249 targets.push(FileAccessTarget { path, access_type });
250}
251
252fn command_text_for_tool(tool_name: &str, args: &Value) -> Option<String> {
253 match tool_name {
254 tools::EXEC_COMMAND => crate::tools::command_args::command_text(args).ok().flatten(),
255 tools::WRITE_STDIN => crate::tools::command_args::interactive_input_text(args).map(str::to_owned),
256 tools::SHELL | tools::RUN_PTY_CMD => crate::tools::command_args::command_text(args).ok().flatten(),
257 tools::SEND_PTY_INPUT => crate::tools::command_args::interactive_input_text(args).map(str::to_owned),
258 tools::UNIFIED_EXEC if command_session_action_is(args, "run") => {
259 crate::tools::command_args::command_text(args).ok().flatten()
260 }
261 tools::UNIFIED_EXEC if command_session_action_in(args, &["write", "continue"]) => {
262 crate::tools::command_args::interactive_input_text(args).map(str::to_owned)
263 }
264 _ => None,
265 }
266}
267
268fn patch_file_access_targets(args: &Value) -> Vec<FileAccessTarget> {
269 let Ok(Some(patch_input)) = decode_apply_patch_input(args) else {
270 return Vec::new();
271 };
272 let Ok(patch) = Patch::parse(&patch_input.text) else {
273 return Vec::new();
274 };
275
276 let mut targets = Vec::new();
277 for operation in patch.operations() {
278 match operation {
279 PatchOperation::AddFile { path, .. } => {
280 push_file_access_target(&mut targets, path, AccessType::Write);
281 }
282 PatchOperation::DeleteFile { path } => {
283 push_file_access_target(&mut targets, path, AccessType::Delete);
284 }
285 PatchOperation::UpdateFile { path, new_path, .. } => {
286 push_file_access_target(&mut targets, path, AccessType::Modify);
287 if let Some(destination) = new_path.as_deref().filter(|candidate| *candidate != path) {
288 push_file_access_target(&mut targets, destination, AccessType::Write);
289 }
290 }
291 }
292 }
293
294 targets
295}
296
297fn file_access_targets(tool_name: &str, args: &Value) -> Vec<FileAccessTarget> {
298 let mut targets = Vec::new();
299
300 match tool_name {
301 tools::WRITE_FILE | tools::CREATE_FILE => {
302 if let Some(path) = primary_path_arg(args) {
303 push_file_access_target(&mut targets, path, AccessType::Write);
304 }
305 }
306 tools::EDIT_FILE | "search_replace" => {
307 if let Some(path) = primary_path_arg(args) {
308 push_file_access_target(&mut targets, path, AccessType::Modify);
309 }
310 }
311 tools::DELETE_FILE => {
312 if let Some(path) = primary_path_arg(args) {
313 push_file_access_target(&mut targets, path, AccessType::Delete);
314 }
315 }
316 tools::MOVE_FILE => {
317 if let Some(path) = primary_path_arg(args) {
318 push_file_access_target(&mut targets, path, AccessType::Modify);
319 }
320 if let Some(path) = destination_path_arg(args) {
321 push_file_access_target(&mut targets, path, AccessType::Write);
322 }
323 }
324 tools::COPY_FILE => {
325 if let Some(path) = destination_path_arg(args) {
326 push_file_access_target(&mut targets, path, AccessType::Write);
327 }
328 }
329 tools::APPLY_PATCH => {
330 targets.extend(patch_file_access_targets(args));
331 }
332 tools::UNIFIED_FILE => {
333 if file_operation_action_is(args, "write") {
336 if let Some(path) = primary_path_arg(args) {
337 push_file_access_target(&mut targets, path, AccessType::Write);
338 }
339 } else if file_operation_action_is(args, "edit") {
340 if let Some(path) = primary_path_arg(args) {
341 push_file_access_target(&mut targets, path, AccessType::Modify);
342 }
343 } else if file_operation_action_is(args, "delete") {
344 if let Some(path) = primary_path_arg(args) {
345 push_file_access_target(&mut targets, path, AccessType::Delete);
346 }
347 } else if file_operation_action_is(args, "move") {
348 if let Some(path) = primary_path_arg(args) {
349 push_file_access_target(&mut targets, path, AccessType::Modify);
350 }
351 if let Some(path) = destination_path_arg(args) {
352 push_file_access_target(&mut targets, path, AccessType::Write);
353 }
354 } else if file_operation_action_is(args, "copy") {
355 if let Some(path) = destination_path_arg(args) {
356 push_file_access_target(&mut targets, path, AccessType::Write);
357 }
358 }
359 }
360 _ => {}
361 }
362
363 targets
364}
365
366fn proposed_changes_preview(args: &Value) -> String {
367 const PREVIEW_LIMIT: usize = 500;
368
369 let preview_text = |label: &str, text: &str| {
370 let prefix = vtcode_commons::formatting::truncate_utf8_prefix(text, PREVIEW_LIMIT);
371 format!("{label} ({} bytes):\n{}{}", text.len(), prefix, if prefix.len() < text.len() { "..." } else { "" })
372 };
373
374 if let Some(content) = args.get("content").and_then(|value| value.as_str()) {
375 return preview_text("Content", content);
376 }
377
378 if let Some(old_str) = args.get("old_str").and_then(|value| value.as_str()) {
379 let new_str = args.get("new_str").and_then(|value| value.as_str()).unwrap_or("");
380 return format!(
383 "Replace:\n '{}'\nWith:\n '{}'",
384 vtcode_commons::formatting::truncate_byte_budget(old_str, PREVIEW_LIMIT, "..."),
385 vtcode_commons::formatting::truncate_byte_budget(new_str, PREVIEW_LIMIT, "...")
386 );
387 }
388
389 if let Ok(Some(patch_input)) = decode_apply_patch_input(args) {
390 return preview_text("Patch", &patch_input.text);
391 }
392
393 "No details provided".to_string()
394}
395
396impl SafetyGateway {
397 pub fn new() -> Self {
399 Self::with_config(SafetyGatewayConfig::default())
400 }
401
402 pub fn with_config(config: SafetyGatewayConfig) -> Self {
404 Self {
405 config: RwLock::new(config),
406 command_policy: None,
407 rate_state: Mutex::new(RateLimiterState::default()),
408 preapproved: Mutex::new(HashSet::new()),
409 dotfile_guardian: None,
410 }
411 }
412
413 pub fn with_dotfile_guardian(mut self, guardian: Arc<DotfileGuardian>) -> Self {
415 self.dotfile_guardian = Some(guardian);
416 self
417 }
418
419 pub async fn with_dotfile_protection(mut self, config: DotfileProtectionConfig) -> anyhow::Result<Self> {
421 let guardian = DotfileGuardian::new(config).await?;
422 self.dotfile_guardian = Some(Arc::new(guardian));
423 Ok(self)
424 }
425
426 pub fn with_command_policy(mut self, policy: CommandPolicyEvaluator) -> Self {
428 self.command_policy = Some(policy);
429 self
430 }
431
432 pub fn with_commands_config(mut self, config: &CommandsConfig) -> Self {
434 self.command_policy = Some(CommandPolicyEvaluator::from_config(config));
435 self
436 }
437
438 pub fn set_planning_workflow(&self, active: bool) {
440 self.config.write().planning_active = active;
441 }
442
443 pub fn set_workspace_trust(&self, trust: WorkspaceTrust) {
445 self.config.write().workspace_trust = trust;
446 }
447
448 pub fn set_limits(&self, max_per_turn: usize, max_per_session: usize) {
450 let mut config = self.config.write();
451 config.max_per_turn = max_per_turn;
452 config.max_per_session = max_per_session;
453 }
454
455 pub fn set_rate_limits(&self, rate_limit_per_second: usize, rate_limit_per_minute: Option<usize>) {
457 let mut config = self.config.write();
458 if rate_limit_per_second > 0 {
459 config.rate_limit_per_second = rate_limit_per_second;
460 }
461 config.rate_limit_per_minute = rate_limit_per_minute.filter(|v| *v > 0);
462 }
463
464 pub fn set_rate_limit_enforcement(&self, enabled: bool) {
466 self.config.write().enforce_rate_limits = enabled;
467 }
468
469 pub fn increase_session_limit(&self, increment: usize) {
471 let mut config = self.config.write();
472 let new_max = config.max_per_session.saturating_add(increment);
473 config.max_per_session = new_max;
474 tracing::info!("Session tool limit increased to {}", new_max);
475 }
476
477 pub fn max_per_session(&self) -> usize {
478 self.config.read().max_per_session
479 }
480
481 pub fn start_turn(&self) {
483 let mut state = self.rate_state.lock();
484 state.current_turn_count = 0;
485 state.control_plane_turn_count = 0;
486 state.calls_per_second.clear();
487 state.calls_per_minute.clear();
488 }
489
490 pub fn reset_for_fresh_execution(&self, max_per_turn: usize, max_per_session: usize) {
493 self.set_limits(max_per_turn, max_per_session);
494 let mut state = self.rate_state.lock();
495 state.current_turn_count = 0;
496 state.control_plane_turn_count = 0;
497 state.session_count = 0;
498 state.calls_per_second.clear();
499 state.calls_per_minute.clear();
500 }
501
502 pub fn preapprove(&self, tool_name: &str) {
504 let mut preapproved = self.preapproved.lock();
505 preapproved.insert(tool_name.to_string());
506 }
507
508 pub fn is_preapproved(&self, tool_name: &str) -> bool {
510 let preapproved = self.preapproved.lock();
511 preapproved.contains(tool_name)
512 }
513
514 pub fn check_safety<'a>(
520 &'a self,
521 ctx: &'a SafetyContext,
522 tool_name: &'a str,
523 args: &'a Value,
524 ) -> impl Future<Output = SafetyDecision> + 'a {
525 self.check_safety_with_id(ctx, tool_name, args, None)
526 }
527
528 pub async fn check_safety_with_id(
530 &self,
531 ctx: &SafetyContext,
532 tool_name: &str,
533 args: &Value,
534 invocation_id: Option<ToolInvocationId>,
535 ) -> SafetyDecision {
536 let inv_id = invocation_id.map(|id| id.short()).unwrap_or_else(|| "unknown".to_string());
537
538 tracing::trace!(
539 invocation_id = %inv_id,
540 tool = %tool_name,
541 "SafetyGateway: checking safety"
542 );
543
544 if let Err(err) = self.check_rate_limits(crate::tools::tool_intent::is_turn_budget_exempt_call(tool_name, args))
545 {
546 tracing::warn!(
547 invocation_id = %inv_id,
548 error = %err,
549 "SafetyGateway: rate limit exceeded"
550 );
551 return SafetyDecision::Deny(err.to_string());
552 }
553
554 self.evaluate_non_rate_decision(ctx, tool_name, args, &inv_id).await
555 }
556
557 pub async fn check_and_record(&self, ctx: &SafetyContext, tool_name: &str, args: &Value) -> SafetyCheckResult {
562 self.check_and_record_with_id(ctx, tool_name, args, None).await
563 }
564
565 pub async fn check_and_record_with_id(
567 &self,
568 ctx: &SafetyContext,
569 tool_name: &str,
570 args: &Value,
571 invocation_id: Option<ToolInvocationId>,
572 ) -> SafetyCheckResult {
573 let inv_id = invocation_id.map(|id| id.short()).unwrap_or_else(|| "unknown".to_string());
574 tracing::trace!(
575 invocation_id = %inv_id,
576 tool = %tool_name,
577 "SafetyGateway: checking and recording safety"
578 );
579
580 let decision = self.evaluate_non_rate_decision(ctx, tool_name, args, &inv_id).await;
581
582 if decision.is_denied() {
583 return SafetyCheckResult { decision, retry_after: None, violation: None };
584 }
585
586 let control_plane = crate::tools::tool_intent::is_turn_budget_exempt_call(tool_name, args);
587 let now = Instant::now();
588 let mut state = self.rate_state.lock();
589 match self.check_rate_limits_locked(&mut state, now, control_plane) {
590 Ok(()) => {
591 self.record_execution_locked(&mut state, now, control_plane);
592 SafetyCheckResult { decision, retry_after: None, violation: None }
593 }
594 Err(err) => {
595 tracing::warn!(
596 invocation_id = %inv_id,
597 error = %err,
598 "SafetyGateway: rate limit exceeded during atomic reservation"
599 );
600 SafetyCheckResult {
601 decision: SafetyDecision::Deny(err.to_string()),
602 retry_after: self.retry_after_for_violation(&err, &state, now),
603 violation: Some(err),
604 }
605 }
606 }
607 }
608
609 async fn evaluate_non_rate_decision(
610 &self,
611 ctx: &SafetyContext,
612 tool_name: &str,
613 args: &Value,
614 inv_id: &str,
615 ) -> SafetyDecision {
616 let command = command_text_for_tool(tool_name, args);
621
622 if let Some(decision) = self.check_dotfile_protection(tool_name, args, &ctx.session_id).await {
623 tracing::info!(
624 invocation_id = %inv_id,
625 tool = %tool_name,
626 "SafetyGateway: dotfile protection triggered"
627 );
628 return decision;
629 }
630
631 if let Some(ref policy) = self.command_policy
632 && let Some(command) = command.as_deref()
633 && !policy.allows_text(command)
634 {
635 let reason = format!("Command '{command}' blocked by policy");
636 tracing::info!(
637 invocation_id = %inv_id,
638 command = %command,
639 "SafetyGateway: command policy denied"
640 );
641 return SafetyDecision::Deny(reason);
642 }
643
644 if self.is_preapproved(tool_name) {
645 tracing::trace!(
646 invocation_id = %inv_id,
647 tool = %tool_name,
648 "SafetyGateway: tool preapproved"
649 );
650 return SafetyDecision::Allow;
651 }
652
653 if ctx.trust_level.can_bypass_approval() {
654 tracing::trace!(
655 invocation_id = %inv_id,
656 tool = %tool_name,
657 trust_level = ?ctx.trust_level,
658 "SafetyGateway: trust level allows bypass"
659 );
660 return SafetyDecision::Allow;
661 }
662
663 let intent = classify_tool_intent(tool_name, args);
667 let command = command.as_deref();
668 let risk_ctx = self.build_risk_context(tool_name, args, &intent, command);
669 let risk_level = ToolRiskScorer::calculate_risk(&risk_ctx);
670
671 if ToolRiskScorer::requires_justification(risk_level, self.config.read().approval_risk_threshold) {
672 let justification = self.build_approval_justification(tool_name, &risk_level, args, &intent, command);
673 tracing::info!(
674 invocation_id = %inv_id,
675 tool = %tool_name,
676 risk = %risk_level,
677 "SafetyGateway: requires approval"
678 );
679 return SafetyDecision::NeedsApproval(justification);
680 }
681
682 if intent.destructive {
683 let justification = format!("Tool '{tool_name}' is destructive and may modify files or execute commands.");
684 tracing::info!(
685 invocation_id = %inv_id,
686 tool = %tool_name,
687 "SafetyGateway: destructive tool requires approval"
688 );
689 return SafetyDecision::NeedsApproval(justification);
690 }
691
692 SafetyDecision::Allow
693 }
694
695 pub fn record_execution(&self) {
697 let mut state = self.rate_state.lock();
698 self.record_execution_locked(&mut state, Instant::now(), false);
699 }
700
701 fn check_rate_limits(&self, control_plane: bool) -> Result<(), SafetyError> {
703 let mut state = self.rate_state.lock();
704 self.check_rate_limits_locked(&mut state, Instant::now(), control_plane)
705 }
706
707 fn check_rate_limits_locked(
708 &self,
709 state: &mut RateLimiterState,
710 now: Instant,
711 control_plane: bool,
712 ) -> Result<(), SafetyError> {
713 let config = self.config.read();
714 self.prune_rate_windows(state, now);
715
716 if config.enforce_rate_limits {
717 if state.calls_per_second.len() >= config.rate_limit_per_second {
718 return Err(SafetyError::RateLimitExceeded {
719 current: state.calls_per_second.len(),
720 max: config.rate_limit_per_second,
721 window: "1s",
722 });
723 }
724
725 if let Some(limit) = config.rate_limit_per_minute
726 && state.calls_per_minute.len() >= limit
727 {
728 return Err(SafetyError::RateLimitExceeded {
729 current: state.calls_per_minute.len(),
730 max: limit,
731 window: "60s",
732 });
733 }
734 }
735
736 if control_plane {
737 if state.control_plane_turn_count >= MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
741 return Err(SafetyError::TurnLimitReached { max: MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN });
742 }
743 } else if state.current_turn_count >= config.max_per_turn {
744 return Err(SafetyError::TurnLimitReached { max: config.max_per_turn });
745 }
746
747 if state.session_count >= config.max_per_session {
748 return Err(SafetyError::SessionLimitReached { max: config.max_per_session });
749 }
750
751 Ok(())
752 }
753
754 fn record_execution_locked(&self, state: &mut RateLimiterState, now: Instant, control_plane: bool) {
755 state.calls_per_second.push_back(now);
756 state.calls_per_minute.push_back(now);
757 state.session_count = state.session_count.saturating_add(1);
758 if control_plane {
759 state.control_plane_turn_count = state.control_plane_turn_count.saturating_add(1);
760 } else {
761 state.current_turn_count = state.current_turn_count.saturating_add(1);
762 }
763 }
764
765 fn prune_rate_windows(&self, state: &mut RateLimiterState, now: Instant) {
766 while let Some(front) = state.calls_per_second.front() {
767 if now.duration_since(*front) > Duration::from_secs(1) {
768 state.calls_per_second.pop_front();
769 } else {
770 break;
771 }
772 }
773 while let Some(front) = state.calls_per_minute.front() {
774 if now.duration_since(*front) > Duration::from_secs(60) {
775 state.calls_per_minute.pop_front();
776 } else {
777 break;
778 }
779 }
780 }
781
782 fn retry_after_for_violation(
783 &self,
784 violation: &SafetyError,
785 state: &RateLimiterState,
786 now: Instant,
787 ) -> Option<Duration> {
788 match violation {
789 SafetyError::RateLimitExceeded { window: "1s", .. } => state
790 .calls_per_second
791 .front()
792 .map(|first| Duration::from_secs(1).saturating_sub(now.duration_since(*first))),
793 SafetyError::RateLimitExceeded { window: "60s", .. } => state
794 .calls_per_minute
795 .front()
796 .map(|first| Duration::from_secs(60).saturating_sub(now.duration_since(*first))),
797 _ => None,
798 }
799 }
800
801 async fn check_dotfile_protection(
804 &self,
805 tool_name: &str,
806 args: &Value,
807 session_id: &str,
808 ) -> Option<SafetyDecision> {
809 let guardian = match self.dotfile_guardian.as_ref() {
811 Some(g) => g.clone(),
812 None => get_global_guardian()?,
813 };
814
815 let file_targets = file_access_targets(tool_name, args);
816 if file_targets.is_empty() {
817 return None;
818 }
819
820 let proposed_changes = proposed_changes_preview(args);
821
822 for target in file_targets {
823 if !guardian.is_protected(Path::new(&target.path)) {
824 continue;
825 }
826
827 let context = AccessContext::new(&target.path, target.access_type, tool_name, session_id)
828 .with_proposed_changes(&proposed_changes);
829
830 match guardian.request_access(&context).await {
831 Ok(ProtectionDecision::Allowed) => continue,
832 Ok(ProtectionDecision::RequiresConfirmation(req)) => {
833 return Some(SafetyDecision::NeedsApproval(format!(
834 "DOTFILE PROTECTION\n\n\
835 File: {}\n\
836 Operation: {}\n\
837 Reason: {}\n\n\
838 Proposed changes:\n{}\n\n\
839 {}",
840 req.file_path, req.access_type, req.protection_reason, req.proposed_changes, req.warning
841 )));
842 }
843 Ok(ProtectionDecision::RequiresSecondaryAuth(req)) => {
844 return Some(SafetyDecision::NeedsApproval(format!(
845 "DOTFILE SECONDARY AUTHENTICATION REQUIRED\n\n\
846 File: {} (whitelisted)\n\
847 Operation: {}\n\
848 Reason: {}\n\n\
849 This file is on the whitelist but requires secondary authentication.\n\n\
850 Proposed changes:\n{}\n\n\
851 {}",
852 req.file_path, req.access_type, req.protection_reason, req.proposed_changes, req.warning
853 )));
854 }
855 Ok(ProtectionDecision::Blocked(violation)) => {
856 return Some(SafetyDecision::Deny(format!(
857 "DOTFILE MODIFICATION BLOCKED\n\n\
858 File: {}\n\
859 Reason: {}\n\n\
860 Suggestion: {}",
861 violation.file_path, violation.reason, violation.suggestion
862 )));
863 }
864 Ok(ProtectionDecision::Denied(violation)) => {
865 return Some(SafetyDecision::Deny(format!(
866 "DOTFILE ACCESS DENIED\n\n\
867 File: {}\n\
868 Reason: {}\n\n\
869 Suggestion: {}",
870 violation.file_path, violation.reason, violation.suggestion
871 )));
872 }
873 Err(e) => {
874 tracing::error!("Dotfile protection check failed: {}", e);
875 return Some(SafetyDecision::Deny(format!("Dotfile protection check failed: {e}")));
876 }
877 }
878 }
879
880 None
881 }
882
883 pub fn dotfile_guardian(&self) -> Option<&Arc<DotfileGuardian>> {
885 self.dotfile_guardian.as_ref()
886 }
887
888 fn build_risk_context(
894 &self,
895 tool_name: &str,
896 args: &Value,
897 intent: &ToolIntent,
898 command: Option<&str>,
899 ) -> ToolRiskContext {
900 let source = if tool_name.starts_with("mcp_") {
901 ToolSource::Mcp
902 } else if tool_name.starts_with("acp_") {
903 ToolSource::Acp
904 } else {
905 ToolSource::Internal
906 };
907
908 let risk_tool_name = action_qualified_policy_name(tool_name, Some(args)).unwrap_or(tool_name);
909
910 let mut ctx = ToolRiskContext::new(risk_tool_name.to_string(), source, self.config.read().workspace_trust);
911
912 if intent.mutating {
914 ctx = ctx.as_write();
915 }
916 if intent.destructive {
917 ctx = ctx.as_destructive();
918 }
919
920 if ToolRiskScorer::is_network_tool(risk_tool_name) {
921 ctx = ctx.accesses_network();
922 }
923
924 if let Some(command) = command {
926 ctx = ctx.with_args(command.split_whitespace().map(String::from).collect());
927 }
928
929 ctx
930 }
931
932 fn build_approval_justification(
938 &self,
939 tool_name: &str,
940 risk_level: &RiskLevel,
941 args: &Value,
942 intent: &ToolIntent,
943 command: Option<&str>,
944 ) -> String {
945 let mut parts = Vec::new();
946
947 parts.push(format!("Tool: {tool_name}"));
948 parts.push(format!("Risk level: {risk_level}"));
949
950 if intent.destructive {
951 parts.push("This tool may modify or delete files.".to_string());
952 }
953
954 if let Some(command) = command {
955 parts.push(format!("Command: {command}"));
956 }
957
958 let file_targets = file_access_targets(tool_name, args);
959 if let Some(target) = file_targets.first() {
960 parts.push(format!("Path: {}", target.path.display()));
961 if file_targets.len() > 1 {
962 parts.push(format!("Additional targets: {}", file_targets.len() - 1));
963 }
964 }
965
966 parts.join("\n")
967 }
968
969 pub fn get_stats(&self) -> SafetyStats {
971 let state = self.rate_state.lock();
972 let preapproved = self.preapproved.lock();
973 let config = self.config.read();
974
975 SafetyStats {
976 turn_count: state.current_turn_count,
977 session_count: state.session_count,
978 max_per_turn: config.max_per_turn,
979 max_per_session: config.max_per_session,
980 planning_active: config.planning_active,
981 preapproved_count: preapproved.len(),
982 }
983 }
984}
985
986impl Default for SafetyGateway {
987 fn default() -> Self {
988 Self::new()
989 }
990}
991
992#[derive(Debug, Clone)]
994pub struct SafetyStats {
995 pub turn_count: usize,
996 pub session_count: usize,
997 pub max_per_turn: usize,
998 pub max_per_session: usize,
999 pub planning_active: bool,
1000 pub preapproved_count: usize,
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use vtcode_config::core::DotfileProtectionConfig;
1007
1008 fn make_ctx() -> SafetyContext {
1009 SafetyContext::new("test-session")
1010 }
1011
1012 #[tokio::test]
1013 async fn test_allow_read_only_tools() {
1014 let gateway = SafetyGateway::new();
1015 let ctx = make_ctx();
1016
1017 let decision = gateway
1018 .check_safety(&ctx, "read_file", &serde_json::json!({"path": "/tmp/test"}))
1019 .await;
1020
1021 assert!(decision.is_allowed());
1022 }
1023
1024 #[tokio::test]
1025 async fn test_destructive_tool_needs_approval() {
1026 let gateway = SafetyGateway::new();
1027 let ctx = make_ctx();
1028
1029 let decision = gateway
1030 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1031 .await;
1032
1033 assert!(decision.needs_approval());
1034 }
1035
1036 #[tokio::test]
1037 async fn test_planning_workflow_does_not_double_block_mutating() {
1038 let gateway = SafetyGateway::new();
1039 gateway.set_planning_workflow(true);
1040 let ctx = make_ctx();
1041
1042 let decision = gateway
1043 .check_safety(&ctx, "apply_patch", &serde_json::json!({"patch": "..."}))
1044 .await;
1045
1046 assert!(decision.needs_approval());
1047 }
1048
1049 #[tokio::test]
1055 async fn command_extraction_feeds_policy_risk_and_justification_identically() {
1056 let policy = CommandPolicyEvaluator::from_config(&CommandsConfig {
1057 allow_list: vec!["cargo".into()],
1058 ..Default::default()
1059 });
1060 let gateway = SafetyGateway::new().with_command_policy(policy);
1061
1062 let allowed = gateway
1065 .check_safety(&make_ctx(), tools::EXEC_COMMAND, &serde_json::json!({"command": "cargo test"}))
1066 .await;
1067 assert!(allowed.is_allowed());
1068
1069 let denied = gateway
1071 .check_safety(&make_ctx(), tools::EXEC_COMMAND, &serde_json::json!({"command": "rm -rf /"}))
1072 .await;
1073 let SafetyDecision::Deny(reason) = denied else {
1074 panic!("policy denial expected");
1075 };
1076 assert!(reason.contains("rm -rf /"), "denial must carry the extracted command: {reason}");
1077 assert!(reason.starts_with("Command 'rm -rf /' blocked by policy"), "unexpected denial shape: {reason}");
1078
1079 gateway.config.write().approval_risk_threshold.clone_from(&RiskLevel::Low);
1082 let justified = gateway
1083 .check_safety(&make_ctx(), tools::EXEC_COMMAND, &serde_json::json!({"command": "cargo publish"}))
1084 .await;
1085 if let SafetyDecision::NeedsApproval(justification) = justified {
1086 assert!(
1087 justification.contains("Command: cargo publish"),
1088 "justification must carry the extracted command: {justification}"
1089 );
1090 }
1091 gateway.config.write().approval_risk_threshold = RiskLevel::High;
1092 }
1093
1094 #[test]
1095 fn build_risk_context_uses_caller_supplied_command() {
1096 let gateway = SafetyGateway::new();
1097 let args = serde_json::json!({"command": "rg --files"});
1098 let intent = classify_tool_intent(tools::EXEC_COMMAND, &args);
1099
1100 let with_command = gateway.build_risk_context(tools::EXEC_COMMAND, &args, &intent, Some("rg --files"));
1101 assert_eq!(with_command.command_args, vec!["rg".to_string(), "--files".to_string()]);
1102
1103 let without_command = gateway.build_risk_context(tools::EXEC_COMMAND, &args, &intent, None);
1104 assert!(without_command.command_args.is_empty());
1105 }
1106
1107 #[tokio::test]
1108 async fn test_preapproved_tools_allowed() {
1109 let gateway = SafetyGateway::new();
1110 gateway.preapprove("delete_file");
1111 let ctx = make_ctx();
1112
1113 let decision = gateway
1114 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1115 .await;
1116
1117 assert!(decision.is_allowed());
1118 }
1119
1120 #[tokio::test]
1121 async fn test_trust_level_bypass() {
1122 let gateway = SafetyGateway::new();
1123 let mut ctx = make_ctx();
1124 ctx.trust_level = SafetyTrustLevel::Full;
1125
1126 let decision = gateway
1127 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1128 .await;
1129
1130 assert!(decision.is_allowed());
1131 }
1132
1133 #[tokio::test]
1134 async fn test_rate_limiting() {
1135 let config = SafetyGatewayConfig { rate_limit_per_second: 2, ..Default::default() };
1136 let gateway = SafetyGateway::with_config(config);
1137 let ctx = make_ctx();
1138
1139 gateway.record_execution();
1141 gateway.record_execution();
1142
1143 let decision = gateway.check_safety(&ctx, "read_file", &serde_json::json!({})).await;
1145
1146 assert!(decision.is_denied());
1147 assert!(decision.reason().unwrap().contains("Rate limit"));
1148 }
1149
1150 #[test]
1151 fn safety_gateway_defaults_use_shared_tool_call_budget() {
1152 let config = SafetyGatewayConfig::default();
1153
1154 assert_eq!(config.max_per_turn, DEFAULT_MAX_TOOL_CALLS_PER_TURN);
1155 assert_eq!(config.max_per_session, DEFAULT_SAFETY_MAX_TOOL_CALLS_PER_SESSION);
1156 }
1157
1158 #[tokio::test]
1159 async fn test_atomic_check_and_record_rate_limited() {
1160 let config = SafetyGatewayConfig { rate_limit_per_second: 2, ..Default::default() };
1161 let gateway = SafetyGateway::with_config(config);
1162 let ctx = make_ctx();
1163
1164 let first = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1165 assert!(first.decision.is_allowed());
1166
1167 let second = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1168 assert!(second.decision.is_allowed());
1169
1170 let third = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1171 assert!(third.decision.is_denied());
1172 assert!(third.retry_after.is_some());
1173 assert!(matches!(third.violation, Some(SafetyError::RateLimitExceeded { .. })));
1174 }
1175
1176 #[tokio::test]
1177 async fn test_command_policy_enforcement() {
1178 let mut commands_config = CommandsConfig::default();
1179 commands_config.deny_list.push("rm".to_string());
1180
1181 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1182 let ctx = make_ctx();
1183
1184 let decision = gateway
1185 .check_safety(&ctx, "shell", &serde_json::json!({"command": "rm -rf /"}))
1186 .await;
1187
1188 assert!(decision.is_denied());
1189 }
1190
1191 #[tokio::test]
1192 async fn test_command_session_command_policy_enforcement_with_indexed_args() {
1193 let mut commands_config = CommandsConfig::default();
1194 commands_config.deny_list.push("rm".to_string());
1195
1196 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1197 let ctx = make_ctx();
1198
1199 let decision = gateway
1200 .check_safety(
1201 &ctx,
1202 tools::UNIFIED_EXEC,
1203 &serde_json::json!({
1204 "command.0": "rm",
1205 "command.1": "-rf",
1206 "command.2": "/"
1207 }),
1208 )
1209 .await;
1210
1211 assert!(decision.is_denied());
1212 }
1213
1214 #[tokio::test]
1215 async fn test_command_session_continue_command_policy_enforcement_with_input() {
1216 let mut commands_config = CommandsConfig::default();
1217 commands_config.deny_list.push("rm".to_string());
1218
1219 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1220 let ctx = make_ctx();
1221
1222 let decision = gateway
1223 .check_safety(
1224 &ctx,
1225 tools::UNIFIED_EXEC,
1226 &serde_json::json!({
1227 "action": "continue",
1228 "session_id": "run-123",
1229 "input": "rm -rf /\n"
1230 }),
1231 )
1232 .await;
1233
1234 assert!(decision.is_denied());
1235 }
1236
1237 #[tokio::test]
1238 async fn test_send_pty_input_command_policy_enforcement_with_input() {
1239 let mut commands_config = CommandsConfig::default();
1240 commands_config.deny_list.push("rm".to_string());
1241
1242 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1243 let ctx = make_ctx();
1244
1245 let decision = gateway
1246 .check_safety(
1247 &ctx,
1248 tools::SEND_PTY_INPUT,
1249 &serde_json::json!({
1250 "session_id": "run-123",
1251 "input": "rm -rf /\n"
1252 }),
1253 )
1254 .await;
1255
1256 assert!(decision.is_denied());
1257 }
1258
1259 #[tokio::test]
1260 async fn test_apply_patch_dotfile_input_dotfile_protection_requires_approval() {
1261 let guardian = Arc::new(
1262 DotfileGuardian::new(DotfileProtectionConfig {
1263 audit_logging_enabled: false,
1264 create_backups: false,
1265 ..Default::default()
1266 })
1267 .await
1268 .expect("guardian should initialize"),
1269 );
1270 let gateway = SafetyGateway::new().with_dotfile_guardian(guardian);
1271 let ctx = make_ctx();
1272
1273 let decision = gateway
1274 .check_safety(
1275 &ctx,
1276 tools::APPLY_PATCH,
1277 &serde_json::json!({
1278 "input": "*** Begin Patch\n*** Update File: .gitignore\n@@\n-old\n+new\n*** End Patch\n"
1279 }),
1280 )
1281 .await;
1282
1283 assert!(decision.needs_approval());
1284 assert!(decision.reason().is_some_and(|reason| reason.contains(".gitignore")));
1285 }
1286
1287 #[test]
1288 fn test_patch_file_access_targets_preserve_patch_order() {
1289 let targets = patch_file_access_targets(&serde_json::json!({
1290 "input": "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-old\n+new\n*** Update File: .gitignore\n@@\n-old\n+new\n*** End Patch\n"
1291 }));
1292
1293 assert_eq!(targets.len(), 2);
1294 assert_eq!(targets[0].path, PathBuf::from("src/main.rs"));
1295 assert_eq!(targets[0].access_type, AccessType::Modify);
1296 assert_eq!(targets[1].path, PathBuf::from(".gitignore"));
1297 assert_eq!(targets[1].access_type, AccessType::Modify);
1298 }
1299
1300 #[test]
1301 fn unified_file_targets_mirror_legacy_file_helpers() {
1302 let write = file_access_targets(tools::UNIFIED_FILE, &serde_json::json!({"action": "write", "path": "a.txt"}));
1303 assert_eq!(write.len(), 1);
1304 assert_eq!(write[0].path, PathBuf::from("a.txt"));
1305 assert_eq!(write[0].access_type, AccessType::Write);
1306
1307 let moved = file_access_targets(
1308 tools::UNIFIED_FILE,
1309 &serde_json::json!({"action": "move", "path": "a.txt", "destination": "b.txt"}),
1310 );
1311 assert_eq!(moved.len(), 2);
1312 assert_eq!(moved[0].access_type, AccessType::Modify);
1313 assert_eq!(moved[1].access_type, AccessType::Write);
1314
1315 let unknown = file_access_targets(tools::UNIFIED_FILE, &serde_json::json!({"action": "read", "path": "a.txt"}));
1316 assert!(unknown.is_empty());
1317 }
1318
1319 #[test]
1320 fn proposed_changes_preview_bounds_old_and_new_str() {
1321 let short = proposed_changes_preview(&serde_json::json!({"old_str": "a", "new_str": "b"}));
1325 assert_eq!(short, "Replace:\n 'a'\nWith:\n 'b'");
1326
1327 let long_old = "a".repeat(600);
1328 let bounded = proposed_changes_preview(&serde_json::json!({"old_str": long_old, "new_str": ""}));
1329 assert!(bounded.contains("..."));
1330 assert!(bounded.len() < 600, "preview must stay bounded: {}", bounded.len());
1331
1332 let boundary_old = format!("{}{}", "a".repeat(499), "日".repeat(10));
1333 let boundary = proposed_changes_preview(&serde_json::json!({"old_str": boundary_old, "new_str": "b"}));
1334 assert!(boundary.contains("..."));
1335 assert!(boundary.ends_with("With:\n 'b'"));
1336 }
1337
1338 #[test]
1339 fn proposed_changes_preview_never_splits_utf8_boundary() {
1340 let content = format!("{}{}", "a".repeat(499), "日".repeat(10));
1343 let preview = proposed_changes_preview(&serde_json::json!({"content": content}));
1344 assert!(preview.contains("Content ("));
1345 assert!(preview.ends_with("..."));
1346 assert!(preview.len() < content.len() + 64);
1347 }
1348
1349 #[tokio::test]
1350 async fn test_apply_patch_patch_field_dotfile_protection_requires_approval() {
1351 let guardian = Arc::new(
1352 DotfileGuardian::new(DotfileProtectionConfig {
1353 audit_logging_enabled: false,
1354 create_backups: false,
1355 ..Default::default()
1356 })
1357 .await
1358 .expect("guardian should initialize"),
1359 );
1360 let gateway = SafetyGateway::new().with_dotfile_guardian(guardian);
1361 let ctx = make_ctx();
1362
1363 let decision = gateway
1364 .check_safety(
1365 &ctx,
1366 tools::APPLY_PATCH,
1367 &serde_json::json!({
1368 "patch": "*** Begin Patch\n*** Update File: .gitignore\n@@\n-old\n+new\n*** End Patch\n"
1369 }),
1370 )
1371 .await;
1372
1373 assert!(decision.needs_approval());
1374 assert!(decision.reason().is_some_and(|reason| reason.contains(".gitignore")));
1375 }
1376
1377 #[tokio::test]
1378 async fn test_stats_tracking() {
1379 let gateway = SafetyGateway::new();
1380 gateway.preapprove("test_tool");
1381 gateway.record_execution();
1382 gateway.record_execution();
1383
1384 let stats = gateway.get_stats();
1385 assert_eq!(stats.turn_count, 2);
1386 assert_eq!(stats.session_count, 2);
1387 assert_eq!(stats.preapproved_count, 1);
1388 }
1389
1390 #[tokio::test]
1391 async fn test_start_turn_resets_counters() {
1392 let gateway = SafetyGateway::new();
1393
1394 gateway.record_execution();
1395 gateway.record_execution();
1396
1397 let stats_before = gateway.get_stats();
1398 assert_eq!(stats_before.turn_count, 2);
1399
1400 gateway.start_turn();
1401
1402 let stats_after = gateway.get_stats();
1403 assert_eq!(stats_after.turn_count, 0);
1404 assert_eq!(stats_after.session_count, 2); }
1406
1407 #[tokio::test]
1408 async fn test_increase_session_limit_updates_limit() {
1409 let gateway = SafetyGateway::new();
1410 gateway.set_limits(10, 1);
1411 let ctx = make_ctx();
1412
1413 let first = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1414 assert!(first.decision.is_allowed());
1415
1416 let second = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1417 assert!(second.decision.is_denied());
1418
1419 gateway.increase_session_limit(1);
1420
1421 let third = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1422 assert!(third.decision.is_allowed());
1423 }
1424
1425 #[tokio::test]
1426 async fn control_plane_wait_does_not_consume_per_turn_budget() {
1427 let config = SafetyGatewayConfig { max_per_turn: 1, ..Default::default() };
1428 let gateway = SafetyGateway::with_config(config);
1429 let ctx = make_ctx();
1430
1431 let work = gateway
1433 .check_and_record(&ctx, "read_file", &serde_json::json!({"path": "x.txt"}))
1434 .await;
1435 assert!(work.decision.is_allowed());
1436
1437 let wait = gateway
1440 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1441 .await;
1442 assert!(wait.decision.is_allowed(), "wait must bypass the per-turn work budget");
1443
1444 let denied = gateway
1446 .check_and_record(&ctx, "read_file", &serde_json::json!({"path": "y.txt"}))
1447 .await;
1448 assert!(denied.decision.is_denied());
1449 }
1450
1451 #[tokio::test]
1452 async fn control_plane_calls_are_bounded_by_their_own_cap() {
1453 let config = SafetyGatewayConfig {
1454 max_per_turn: 1_000,
1455 enforce_rate_limits: false,
1456 ..Default::default()
1457 };
1458 let gateway = SafetyGateway::with_config(config);
1459 let ctx = make_ctx();
1460
1461 for _ in 0..MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
1462 let call = gateway
1463 .check_and_record(
1464 &ctx,
1465 tools::UNIFIED_EXEC,
1466 &serde_json::json!({"action": "wait", "session_id": "run-1"}),
1467 )
1468 .await;
1469 assert!(call.decision.is_allowed());
1470 }
1471
1472 let overflow = gateway
1474 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1475 .await;
1476 assert!(overflow.decision.is_denied());
1477 assert!(overflow.decision.reason().is_some_and(|reason| reason.contains("limit")));
1478 }
1479
1480 #[tokio::test]
1481 async fn control_plane_calls_still_consume_session_and_rate_budget() {
1482 let config = SafetyGatewayConfig {
1484 max_per_turn: 1_000,
1485 max_per_session: 2,
1486 enforce_rate_limits: false,
1487 ..Default::default()
1488 };
1489 let gateway = SafetyGateway::with_config(config);
1490 let ctx = make_ctx();
1491
1492 for _ in 0..2 {
1493 let call = gateway
1494 .check_and_record(
1495 &ctx,
1496 tools::UNIFIED_EXEC,
1497 &serde_json::json!({"action": "wait", "session_id": "run-1"}),
1498 )
1499 .await;
1500 assert!(call.decision.is_allowed());
1501 }
1502 let third = gateway
1503 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1504 .await;
1505 assert!(third.decision.is_denied(), "session fuse must bound control-plane calls");
1506 }
1507
1508 #[tokio::test]
1509 async fn start_turn_resets_control_plane_counter() {
1510 let config = SafetyGatewayConfig {
1511 max_per_turn: 1_000,
1512 enforce_rate_limits: false,
1513 ..Default::default()
1514 };
1515 let gateway = SafetyGateway::with_config(config);
1516 let ctx = make_ctx();
1517 let wait_args = serde_json::json!({"action": "wait", "session_id": "run-1"});
1518
1519 for _ in 0..MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
1520 assert!(
1521 gateway
1522 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1523 .await
1524 .decision
1525 .is_allowed()
1526 );
1527 }
1528 assert!(
1529 gateway
1530 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1531 .await
1532 .decision
1533 .is_denied()
1534 );
1535
1536 gateway.start_turn();
1537
1538 assert!(
1539 gateway
1540 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1541 .await
1542 .decision
1543 .is_allowed(),
1544 "a fresh turn must refill the control-plane bucket"
1545 );
1546 }
1547}