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 if file_operation_action_is(args, "write") => {
333 if let Some(path) = primary_path_arg(args) {
334 push_file_access_target(&mut targets, path, AccessType::Write);
335 }
336 }
337 tools::UNIFIED_FILE if file_operation_action_is(args, "edit") => {
338 if let Some(path) = primary_path_arg(args) {
339 push_file_access_target(&mut targets, path, AccessType::Modify);
340 }
341 }
342 tools::UNIFIED_FILE if file_operation_action_is(args, "delete") => {
343 if let Some(path) = primary_path_arg(args) {
344 push_file_access_target(&mut targets, path, AccessType::Delete);
345 }
346 }
347 tools::UNIFIED_FILE 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 }
355 tools::UNIFIED_FILE if file_operation_action_is(args, "copy") => {
356 if let Some(path) = destination_path_arg(args) {
357 push_file_access_target(&mut targets, path, AccessType::Write);
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 preview_len = text.len().min(PREVIEW_LIMIT);
371 format!(
372 "{label} ({} bytes):\n{}{}",
373 text.len(),
374 &text[..preview_len],
375 if text.len() > preview_len { "..." } else { "" }
376 )
377 };
378
379 if let Some(content) = args.get("content").and_then(|value| value.as_str()) {
380 return preview_text("Content", content);
381 }
382
383 if let Some(old_str) = args.get("old_str").and_then(|value| value.as_str()) {
384 let new_str = args.get("new_str").and_then(|value| value.as_str()).unwrap_or("");
385 return format!("Replace:\n '{old_str}'\nWith:\n '{new_str}'");
386 }
387
388 if let Ok(Some(patch_input)) = decode_apply_patch_input(args) {
389 return preview_text("Patch", &patch_input.text);
390 }
391
392 "No details provided".to_string()
393}
394
395impl SafetyGateway {
396 pub fn new() -> Self {
398 Self::with_config(SafetyGatewayConfig::default())
399 }
400
401 pub fn with_config(config: SafetyGatewayConfig) -> Self {
403 Self {
404 config: RwLock::new(config),
405 command_policy: None,
406 rate_state: Mutex::new(RateLimiterState::default()),
407 preapproved: Mutex::new(HashSet::new()),
408 dotfile_guardian: None,
409 }
410 }
411
412 pub fn with_dotfile_guardian(mut self, guardian: Arc<DotfileGuardian>) -> Self {
414 self.dotfile_guardian = Some(guardian);
415 self
416 }
417
418 pub async fn with_dotfile_protection(mut self, config: DotfileProtectionConfig) -> anyhow::Result<Self> {
420 let guardian = DotfileGuardian::new(config).await?;
421 self.dotfile_guardian = Some(Arc::new(guardian));
422 Ok(self)
423 }
424
425 pub fn with_command_policy(mut self, policy: CommandPolicyEvaluator) -> Self {
427 self.command_policy = Some(policy);
428 self
429 }
430
431 pub fn with_commands_config(mut self, config: &CommandsConfig) -> Self {
433 self.command_policy = Some(CommandPolicyEvaluator::from_config(config));
434 self
435 }
436
437 pub fn set_planning_workflow(&self, active: bool) {
439 self.config.write().planning_active = active;
440 }
441
442 pub fn set_workspace_trust(&self, trust: WorkspaceTrust) {
444 self.config.write().workspace_trust = trust;
445 }
446
447 pub fn set_limits(&self, max_per_turn: usize, max_per_session: usize) {
449 let mut config = self.config.write();
450 config.max_per_turn = max_per_turn;
451 config.max_per_session = max_per_session;
452 }
453
454 pub fn set_rate_limits(&self, rate_limit_per_second: usize, rate_limit_per_minute: Option<usize>) {
456 let mut config = self.config.write();
457 if rate_limit_per_second > 0 {
458 config.rate_limit_per_second = rate_limit_per_second;
459 }
460 config.rate_limit_per_minute = rate_limit_per_minute.filter(|v| *v > 0);
461 }
462
463 pub fn set_rate_limit_enforcement(&self, enabled: bool) {
465 self.config.write().enforce_rate_limits = enabled;
466 }
467
468 pub fn increase_session_limit(&self, increment: usize) {
470 let mut config = self.config.write();
471 let new_max = config.max_per_session.saturating_add(increment);
472 config.max_per_session = new_max;
473 tracing::info!("Session tool limit increased to {}", new_max);
474 }
475
476 pub fn max_per_session(&self) -> usize {
477 self.config.read().max_per_session
478 }
479
480 pub fn start_turn(&self) {
482 let mut state = self.rate_state.lock();
483 state.current_turn_count = 0;
484 state.control_plane_turn_count = 0;
485 state.calls_per_second.clear();
486 state.calls_per_minute.clear();
487 }
488
489 pub fn reset_for_fresh_execution(&self, max_per_turn: usize, max_per_session: usize) {
492 self.set_limits(max_per_turn, max_per_session);
493 let mut state = self.rate_state.lock();
494 state.current_turn_count = 0;
495 state.control_plane_turn_count = 0;
496 state.session_count = 0;
497 state.calls_per_second.clear();
498 state.calls_per_minute.clear();
499 }
500
501 pub fn preapprove(&self, tool_name: &str) {
503 let mut preapproved = self.preapproved.lock();
504 preapproved.insert(tool_name.to_string());
505 }
506
507 pub fn is_preapproved(&self, tool_name: &str) -> bool {
509 let preapproved = self.preapproved.lock();
510 preapproved.contains(tool_name)
511 }
512
513 pub fn check_safety<'a>(
519 &'a self,
520 ctx: &'a SafetyContext,
521 tool_name: &'a str,
522 args: &'a Value,
523 ) -> impl Future<Output = SafetyDecision> + 'a {
524 self.check_safety_with_id(ctx, tool_name, args, None)
525 }
526
527 pub async fn check_safety_with_id(
529 &self,
530 ctx: &SafetyContext,
531 tool_name: &str,
532 args: &Value,
533 invocation_id: Option<ToolInvocationId>,
534 ) -> SafetyDecision {
535 let inv_id = invocation_id.map(|id| id.short()).unwrap_or_else(|| "unknown".to_string());
536
537 tracing::trace!(
538 invocation_id = %inv_id,
539 tool = %tool_name,
540 "SafetyGateway: checking safety"
541 );
542
543 if let Err(err) = self.check_rate_limits(crate::tools::tool_intent::is_turn_budget_exempt_call(tool_name, args))
544 {
545 tracing::warn!(
546 invocation_id = %inv_id,
547 error = %err,
548 "SafetyGateway: rate limit exceeded"
549 );
550 return SafetyDecision::Deny(err.to_string());
551 }
552
553 self.evaluate_non_rate_decision(ctx, tool_name, args, &inv_id).await
554 }
555
556 pub async fn check_and_record(&self, ctx: &SafetyContext, tool_name: &str, args: &Value) -> SafetyCheckResult {
561 self.check_and_record_with_id(ctx, tool_name, args, None).await
562 }
563
564 pub async fn check_and_record_with_id(
566 &self,
567 ctx: &SafetyContext,
568 tool_name: &str,
569 args: &Value,
570 invocation_id: Option<ToolInvocationId>,
571 ) -> SafetyCheckResult {
572 let inv_id = invocation_id.map(|id| id.short()).unwrap_or_else(|| "unknown".to_string());
573 tracing::trace!(
574 invocation_id = %inv_id,
575 tool = %tool_name,
576 "SafetyGateway: checking and recording safety"
577 );
578
579 let decision = self.evaluate_non_rate_decision(ctx, tool_name, args, &inv_id).await;
580
581 if decision.is_denied() {
582 return SafetyCheckResult { decision, retry_after: None, violation: None };
583 }
584
585 let control_plane = crate::tools::tool_intent::is_turn_budget_exempt_call(tool_name, args);
586 let now = Instant::now();
587 let mut state = self.rate_state.lock();
588 match self.check_rate_limits_locked(&mut state, now, control_plane) {
589 Ok(()) => {
590 self.record_execution_locked(&mut state, now, control_plane);
591 SafetyCheckResult { decision, retry_after: None, violation: None }
592 }
593 Err(err) => {
594 tracing::warn!(
595 invocation_id = %inv_id,
596 error = %err,
597 "SafetyGateway: rate limit exceeded during atomic reservation"
598 );
599 SafetyCheckResult {
600 decision: SafetyDecision::Deny(err.to_string()),
601 retry_after: self.retry_after_for_violation(&err, &state, now),
602 violation: Some(err),
603 }
604 }
605 }
606 }
607
608 async fn evaluate_non_rate_decision(
609 &self,
610 ctx: &SafetyContext,
611 tool_name: &str,
612 args: &Value,
613 inv_id: &str,
614 ) -> SafetyDecision {
615 if let Some(decision) = self.check_dotfile_protection(tool_name, args, &ctx.session_id).await {
616 tracing::info!(
617 invocation_id = %inv_id,
618 tool = %tool_name,
619 "SafetyGateway: dotfile protection triggered"
620 );
621 return decision;
622 }
623
624 if let Some(ref policy) = self.command_policy
625 && let Some(command) = command_text_for_tool(tool_name, args)
626 && !policy.allows_text(&command)
627 {
628 let reason = format!("Command '{command}' blocked by policy");
629 tracing::info!(
630 invocation_id = %inv_id,
631 command = %command,
632 "SafetyGateway: command policy denied"
633 );
634 return SafetyDecision::Deny(reason);
635 }
636
637 if self.is_preapproved(tool_name) {
638 tracing::trace!(
639 invocation_id = %inv_id,
640 tool = %tool_name,
641 "SafetyGateway: tool preapproved"
642 );
643 return SafetyDecision::Allow;
644 }
645
646 if ctx.trust_level.can_bypass_approval() {
647 tracing::trace!(
648 invocation_id = %inv_id,
649 tool = %tool_name,
650 trust_level = ?ctx.trust_level,
651 "SafetyGateway: trust level allows bypass"
652 );
653 return SafetyDecision::Allow;
654 }
655
656 let intent = classify_tool_intent(tool_name, args);
660 let risk_ctx = self.build_risk_context(tool_name, args, &intent);
661 let risk_level = ToolRiskScorer::calculate_risk(&risk_ctx);
662
663 if ToolRiskScorer::requires_justification(risk_level, self.config.read().approval_risk_threshold) {
664 let justification = self.build_approval_justification(tool_name, &risk_level, args, &intent);
665 tracing::info!(
666 invocation_id = %inv_id,
667 tool = %tool_name,
668 risk = %risk_level,
669 "SafetyGateway: requires approval"
670 );
671 return SafetyDecision::NeedsApproval(justification);
672 }
673
674 if intent.destructive {
675 let justification = format!("Tool '{tool_name}' is destructive and may modify files or execute commands.");
676 tracing::info!(
677 invocation_id = %inv_id,
678 tool = %tool_name,
679 "SafetyGateway: destructive tool requires approval"
680 );
681 return SafetyDecision::NeedsApproval(justification);
682 }
683
684 SafetyDecision::Allow
685 }
686
687 pub fn record_execution(&self) {
689 let mut state = self.rate_state.lock();
690 self.record_execution_locked(&mut state, Instant::now(), false);
691 }
692
693 fn check_rate_limits(&self, control_plane: bool) -> Result<(), SafetyError> {
695 let mut state = self.rate_state.lock();
696 self.check_rate_limits_locked(&mut state, Instant::now(), control_plane)
697 }
698
699 fn check_rate_limits_locked(
700 &self,
701 state: &mut RateLimiterState,
702 now: Instant,
703 control_plane: bool,
704 ) -> Result<(), SafetyError> {
705 let config = self.config.read();
706 self.prune_rate_windows(state, now);
707
708 if config.enforce_rate_limits {
709 if state.calls_per_second.len() >= config.rate_limit_per_second {
710 return Err(SafetyError::RateLimitExceeded {
711 current: state.calls_per_second.len(),
712 max: config.rate_limit_per_second,
713 window: "1s",
714 });
715 }
716
717 if let Some(limit) = config.rate_limit_per_minute
718 && state.calls_per_minute.len() >= limit
719 {
720 return Err(SafetyError::RateLimitExceeded {
721 current: state.calls_per_minute.len(),
722 max: limit,
723 window: "60s",
724 });
725 }
726 }
727
728 if control_plane {
729 if state.control_plane_turn_count >= MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
733 return Err(SafetyError::TurnLimitReached { max: MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN });
734 }
735 } else if state.current_turn_count >= config.max_per_turn {
736 return Err(SafetyError::TurnLimitReached { max: config.max_per_turn });
737 }
738
739 if state.session_count >= config.max_per_session {
740 return Err(SafetyError::SessionLimitReached { max: config.max_per_session });
741 }
742
743 Ok(())
744 }
745
746 fn record_execution_locked(&self, state: &mut RateLimiterState, now: Instant, control_plane: bool) {
747 state.calls_per_second.push_back(now);
748 state.calls_per_minute.push_back(now);
749 state.session_count = state.session_count.saturating_add(1);
750 if control_plane {
751 state.control_plane_turn_count = state.control_plane_turn_count.saturating_add(1);
752 } else {
753 state.current_turn_count = state.current_turn_count.saturating_add(1);
754 }
755 }
756
757 fn prune_rate_windows(&self, state: &mut RateLimiterState, now: Instant) {
758 while let Some(front) = state.calls_per_second.front() {
759 if now.duration_since(*front) > Duration::from_secs(1) {
760 state.calls_per_second.pop_front();
761 } else {
762 break;
763 }
764 }
765 while let Some(front) = state.calls_per_minute.front() {
766 if now.duration_since(*front) > Duration::from_secs(60) {
767 state.calls_per_minute.pop_front();
768 } else {
769 break;
770 }
771 }
772 }
773
774 fn retry_after_for_violation(
775 &self,
776 violation: &SafetyError,
777 state: &RateLimiterState,
778 now: Instant,
779 ) -> Option<Duration> {
780 match violation {
781 SafetyError::RateLimitExceeded { window: "1s", .. } => state
782 .calls_per_second
783 .front()
784 .map(|first| Duration::from_secs(1).saturating_sub(now.duration_since(*first))),
785 SafetyError::RateLimitExceeded { window: "60s", .. } => state
786 .calls_per_minute
787 .front()
788 .map(|first| Duration::from_secs(60).saturating_sub(now.duration_since(*first))),
789 _ => None,
790 }
791 }
792
793 async fn check_dotfile_protection(
796 &self,
797 tool_name: &str,
798 args: &Value,
799 session_id: &str,
800 ) -> Option<SafetyDecision> {
801 let guardian = match self.dotfile_guardian.as_ref() {
803 Some(g) => g.clone(),
804 None => get_global_guardian()?,
805 };
806
807 let file_targets = file_access_targets(tool_name, args);
808 if file_targets.is_empty() {
809 return None;
810 }
811
812 let proposed_changes = proposed_changes_preview(args);
813
814 for target in file_targets {
815 if !guardian.is_protected(Path::new(&target.path)) {
816 continue;
817 }
818
819 let context = AccessContext::new(&target.path, target.access_type, tool_name, session_id)
820 .with_proposed_changes(&proposed_changes);
821
822 match guardian.request_access(&context).await {
823 Ok(ProtectionDecision::Allowed) => continue,
824 Ok(ProtectionDecision::RequiresConfirmation(req)) => {
825 return Some(SafetyDecision::NeedsApproval(format!(
826 "DOTFILE PROTECTION\n\n\
827 File: {}\n\
828 Operation: {}\n\
829 Reason: {}\n\n\
830 Proposed changes:\n{}\n\n\
831 {}",
832 req.file_path, req.access_type, req.protection_reason, req.proposed_changes, req.warning
833 )));
834 }
835 Ok(ProtectionDecision::RequiresSecondaryAuth(req)) => {
836 return Some(SafetyDecision::NeedsApproval(format!(
837 "DOTFILE SECONDARY AUTHENTICATION REQUIRED\n\n\
838 File: {} (whitelisted)\n\
839 Operation: {}\n\
840 Reason: {}\n\n\
841 This file is on the whitelist but requires secondary authentication.\n\n\
842 Proposed changes:\n{}\n\n\
843 {}",
844 req.file_path, req.access_type, req.protection_reason, req.proposed_changes, req.warning
845 )));
846 }
847 Ok(ProtectionDecision::Blocked(violation)) => {
848 return Some(SafetyDecision::Deny(format!(
849 "DOTFILE MODIFICATION BLOCKED\n\n\
850 File: {}\n\
851 Reason: {}\n\n\
852 Suggestion: {}",
853 violation.file_path, violation.reason, violation.suggestion
854 )));
855 }
856 Ok(ProtectionDecision::Denied(violation)) => {
857 return Some(SafetyDecision::Deny(format!(
858 "DOTFILE ACCESS DENIED\n\n\
859 File: {}\n\
860 Reason: {}\n\n\
861 Suggestion: {}",
862 violation.file_path, violation.reason, violation.suggestion
863 )));
864 }
865 Err(e) => {
866 tracing::error!("Dotfile protection check failed: {}", e);
867 return Some(SafetyDecision::Deny(format!("Dotfile protection check failed: {e}")));
868 }
869 }
870 }
871
872 None
873 }
874
875 pub fn dotfile_guardian(&self) -> Option<&Arc<DotfileGuardian>> {
877 self.dotfile_guardian.as_ref()
878 }
879
880 fn build_risk_context(&self, tool_name: &str, args: &Value, intent: &ToolIntent) -> ToolRiskContext {
882 let source = if tool_name.starts_with("mcp_") {
883 ToolSource::Mcp
884 } else if tool_name.starts_with("acp_") {
885 ToolSource::Acp
886 } else {
887 ToolSource::Internal
888 };
889
890 let risk_tool_name = action_qualified_policy_name(tool_name, Some(args)).unwrap_or(tool_name);
891
892 let mut ctx = ToolRiskContext::new(risk_tool_name.to_string(), source, self.config.read().workspace_trust);
893
894 if intent.mutating {
896 ctx = ctx.as_write();
897 }
898 if intent.destructive {
899 ctx = ctx.as_destructive();
900 }
901
902 if ToolRiskScorer::is_network_tool(risk_tool_name) {
903 ctx = ctx.accesses_network();
904 }
905
906 if let Some(command) = command_text_for_tool(tool_name, args) {
908 ctx = ctx.with_args(command.split_whitespace().map(String::from).collect());
909 }
910
911 ctx
912 }
913
914 fn build_approval_justification(
916 &self,
917 tool_name: &str,
918 risk_level: &RiskLevel,
919 args: &Value,
920 intent: &ToolIntent,
921 ) -> String {
922 let mut parts = Vec::new();
923
924 parts.push(format!("Tool: {tool_name}"));
925 parts.push(format!("Risk level: {risk_level}"));
926
927 if intent.destructive {
928 parts.push("This tool may modify or delete files.".to_string());
929 }
930
931 if let Some(command) = command_text_for_tool(tool_name, args) {
932 parts.push(format!("Command: {command}"));
933 }
934
935 let file_targets = file_access_targets(tool_name, args);
936 if let Some(target) = file_targets.first() {
937 parts.push(format!("Path: {}", target.path.display()));
938 if file_targets.len() > 1 {
939 parts.push(format!("Additional targets: {}", file_targets.len() - 1));
940 }
941 }
942
943 parts.join("\n")
944 }
945
946 pub fn get_stats(&self) -> SafetyStats {
948 let state = self.rate_state.lock();
949 let preapproved = self.preapproved.lock();
950 let config = self.config.read();
951
952 SafetyStats {
953 turn_count: state.current_turn_count,
954 session_count: state.session_count,
955 max_per_turn: config.max_per_turn,
956 max_per_session: config.max_per_session,
957 planning_active: config.planning_active,
958 preapproved_count: preapproved.len(),
959 }
960 }
961}
962
963impl Default for SafetyGateway {
964 fn default() -> Self {
965 Self::new()
966 }
967}
968
969#[derive(Debug, Clone)]
971pub struct SafetyStats {
972 pub turn_count: usize,
973 pub session_count: usize,
974 pub max_per_turn: usize,
975 pub max_per_session: usize,
976 pub planning_active: bool,
977 pub preapproved_count: usize,
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983 use vtcode_config::core::DotfileProtectionConfig;
984
985 fn make_ctx() -> SafetyContext {
986 SafetyContext::new("test-session")
987 }
988
989 #[tokio::test]
990 async fn test_allow_read_only_tools() {
991 let gateway = SafetyGateway::new();
992 let ctx = make_ctx();
993
994 let decision = gateway
995 .check_safety(&ctx, "read_file", &serde_json::json!({"path": "/tmp/test"}))
996 .await;
997
998 assert!(decision.is_allowed());
999 }
1000
1001 #[tokio::test]
1002 async fn test_destructive_tool_needs_approval() {
1003 let gateway = SafetyGateway::new();
1004 let ctx = make_ctx();
1005
1006 let decision = gateway
1007 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1008 .await;
1009
1010 assert!(decision.needs_approval());
1011 }
1012
1013 #[tokio::test]
1014 async fn test_planning_workflow_does_not_double_block_mutating() {
1015 let gateway = SafetyGateway::new();
1016 gateway.set_planning_workflow(true);
1017 let ctx = make_ctx();
1018
1019 let decision = gateway
1020 .check_safety(&ctx, "apply_patch", &serde_json::json!({"patch": "..."}))
1021 .await;
1022
1023 assert!(decision.needs_approval());
1024 }
1025
1026 #[tokio::test]
1027 async fn test_preapproved_tools_allowed() {
1028 let gateway = SafetyGateway::new();
1029 gateway.preapprove("delete_file");
1030 let ctx = make_ctx();
1031
1032 let decision = gateway
1033 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1034 .await;
1035
1036 assert!(decision.is_allowed());
1037 }
1038
1039 #[tokio::test]
1040 async fn test_trust_level_bypass() {
1041 let gateway = SafetyGateway::new();
1042 let mut ctx = make_ctx();
1043 ctx.trust_level = SafetyTrustLevel::Full;
1044
1045 let decision = gateway
1046 .check_safety(&ctx, "delete_file", &serde_json::json!({"path": "/tmp/test"}))
1047 .await;
1048
1049 assert!(decision.is_allowed());
1050 }
1051
1052 #[tokio::test]
1053 async fn test_rate_limiting() {
1054 let config = SafetyGatewayConfig { rate_limit_per_second: 2, ..Default::default() };
1055 let gateway = SafetyGateway::with_config(config);
1056 let ctx = make_ctx();
1057
1058 gateway.record_execution();
1060 gateway.record_execution();
1061
1062 let decision = gateway.check_safety(&ctx, "read_file", &serde_json::json!({})).await;
1064
1065 assert!(decision.is_denied());
1066 assert!(decision.reason().unwrap().contains("Rate limit"));
1067 }
1068
1069 #[test]
1070 fn safety_gateway_defaults_use_shared_tool_call_budget() {
1071 let config = SafetyGatewayConfig::default();
1072
1073 assert_eq!(config.max_per_turn, DEFAULT_MAX_TOOL_CALLS_PER_TURN);
1074 assert_eq!(config.max_per_session, DEFAULT_SAFETY_MAX_TOOL_CALLS_PER_SESSION);
1075 }
1076
1077 #[tokio::test]
1078 async fn test_atomic_check_and_record_rate_limited() {
1079 let config = SafetyGatewayConfig { rate_limit_per_second: 2, ..Default::default() };
1080 let gateway = SafetyGateway::with_config(config);
1081 let ctx = make_ctx();
1082
1083 let first = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1084 assert!(first.decision.is_allowed());
1085
1086 let second = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1087 assert!(second.decision.is_allowed());
1088
1089 let third = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1090 assert!(third.decision.is_denied());
1091 assert!(third.retry_after.is_some());
1092 assert!(matches!(third.violation, Some(SafetyError::RateLimitExceeded { .. })));
1093 }
1094
1095 #[tokio::test]
1096 async fn test_command_policy_enforcement() {
1097 let mut commands_config = CommandsConfig::default();
1098 commands_config.deny_list.push("rm".to_string());
1099
1100 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1101 let ctx = make_ctx();
1102
1103 let decision = gateway
1104 .check_safety(&ctx, "shell", &serde_json::json!({"command": "rm -rf /"}))
1105 .await;
1106
1107 assert!(decision.is_denied());
1108 }
1109
1110 #[tokio::test]
1111 async fn test_command_session_command_policy_enforcement_with_indexed_args() {
1112 let mut commands_config = CommandsConfig::default();
1113 commands_config.deny_list.push("rm".to_string());
1114
1115 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1116 let ctx = make_ctx();
1117
1118 let decision = gateway
1119 .check_safety(
1120 &ctx,
1121 tools::UNIFIED_EXEC,
1122 &serde_json::json!({
1123 "command.0": "rm",
1124 "command.1": "-rf",
1125 "command.2": "/"
1126 }),
1127 )
1128 .await;
1129
1130 assert!(decision.is_denied());
1131 }
1132
1133 #[tokio::test]
1134 async fn test_command_session_continue_command_policy_enforcement_with_input() {
1135 let mut commands_config = CommandsConfig::default();
1136 commands_config.deny_list.push("rm".to_string());
1137
1138 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1139 let ctx = make_ctx();
1140
1141 let decision = gateway
1142 .check_safety(
1143 &ctx,
1144 tools::UNIFIED_EXEC,
1145 &serde_json::json!({
1146 "action": "continue",
1147 "session_id": "run-123",
1148 "input": "rm -rf /\n"
1149 }),
1150 )
1151 .await;
1152
1153 assert!(decision.is_denied());
1154 }
1155
1156 #[tokio::test]
1157 async fn test_send_pty_input_command_policy_enforcement_with_input() {
1158 let mut commands_config = CommandsConfig::default();
1159 commands_config.deny_list.push("rm".to_string());
1160
1161 let gateway = SafetyGateway::new().with_commands_config(&commands_config);
1162 let ctx = make_ctx();
1163
1164 let decision = gateway
1165 .check_safety(
1166 &ctx,
1167 tools::SEND_PTY_INPUT,
1168 &serde_json::json!({
1169 "session_id": "run-123",
1170 "input": "rm -rf /\n"
1171 }),
1172 )
1173 .await;
1174
1175 assert!(decision.is_denied());
1176 }
1177
1178 #[tokio::test]
1179 async fn test_apply_patch_dotfile_input_dotfile_protection_requires_approval() {
1180 let guardian = Arc::new(
1181 DotfileGuardian::new(DotfileProtectionConfig {
1182 audit_logging_enabled: false,
1183 create_backups: false,
1184 ..Default::default()
1185 })
1186 .await
1187 .expect("guardian should initialize"),
1188 );
1189 let gateway = SafetyGateway::new().with_dotfile_guardian(guardian);
1190 let ctx = make_ctx();
1191
1192 let decision = gateway
1193 .check_safety(
1194 &ctx,
1195 tools::APPLY_PATCH,
1196 &serde_json::json!({
1197 "input": "*** Begin Patch\n*** Update File: .gitignore\n@@\n-old\n+new\n*** End Patch\n"
1198 }),
1199 )
1200 .await;
1201
1202 assert!(decision.needs_approval());
1203 assert!(decision.reason().is_some_and(|reason| reason.contains(".gitignore")));
1204 }
1205
1206 #[test]
1207 fn test_patch_file_access_targets_preserve_patch_order() {
1208 let targets = patch_file_access_targets(&serde_json::json!({
1209 "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"
1210 }));
1211
1212 assert_eq!(targets.len(), 2);
1213 assert_eq!(targets[0].path, PathBuf::from("src/main.rs"));
1214 assert_eq!(targets[0].access_type, AccessType::Modify);
1215 assert_eq!(targets[1].path, PathBuf::from(".gitignore"));
1216 assert_eq!(targets[1].access_type, AccessType::Modify);
1217 }
1218
1219 #[tokio::test]
1220 async fn test_apply_patch_patch_field_dotfile_protection_requires_approval() {
1221 let guardian = Arc::new(
1222 DotfileGuardian::new(DotfileProtectionConfig {
1223 audit_logging_enabled: false,
1224 create_backups: false,
1225 ..Default::default()
1226 })
1227 .await
1228 .expect("guardian should initialize"),
1229 );
1230 let gateway = SafetyGateway::new().with_dotfile_guardian(guardian);
1231 let ctx = make_ctx();
1232
1233 let decision = gateway
1234 .check_safety(
1235 &ctx,
1236 tools::APPLY_PATCH,
1237 &serde_json::json!({
1238 "patch": "*** Begin Patch\n*** Update File: .gitignore\n@@\n-old\n+new\n*** End Patch\n"
1239 }),
1240 )
1241 .await;
1242
1243 assert!(decision.needs_approval());
1244 assert!(decision.reason().is_some_and(|reason| reason.contains(".gitignore")));
1245 }
1246
1247 #[tokio::test]
1248 async fn test_stats_tracking() {
1249 let gateway = SafetyGateway::new();
1250 gateway.preapprove("test_tool");
1251 gateway.record_execution();
1252 gateway.record_execution();
1253
1254 let stats = gateway.get_stats();
1255 assert_eq!(stats.turn_count, 2);
1256 assert_eq!(stats.session_count, 2);
1257 assert_eq!(stats.preapproved_count, 1);
1258 }
1259
1260 #[tokio::test]
1261 async fn test_start_turn_resets_counters() {
1262 let gateway = SafetyGateway::new();
1263
1264 gateway.record_execution();
1265 gateway.record_execution();
1266
1267 let stats_before = gateway.get_stats();
1268 assert_eq!(stats_before.turn_count, 2);
1269
1270 gateway.start_turn();
1271
1272 let stats_after = gateway.get_stats();
1273 assert_eq!(stats_after.turn_count, 0);
1274 assert_eq!(stats_after.session_count, 2); }
1276
1277 #[tokio::test]
1278 async fn test_increase_session_limit_updates_limit() {
1279 let gateway = SafetyGateway::new();
1280 gateway.set_limits(10, 1);
1281 let ctx = make_ctx();
1282
1283 let first = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1284 assert!(first.decision.is_allowed());
1285
1286 let second = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1287 assert!(second.decision.is_denied());
1288
1289 gateway.increase_session_limit(1);
1290
1291 let third = gateway.check_and_record(&ctx, "read_file", &serde_json::json!({})).await;
1292 assert!(third.decision.is_allowed());
1293 }
1294
1295 #[tokio::test]
1296 async fn control_plane_wait_does_not_consume_per_turn_budget() {
1297 let config = SafetyGatewayConfig { max_per_turn: 1, ..Default::default() };
1298 let gateway = SafetyGateway::with_config(config);
1299 let ctx = make_ctx();
1300
1301 let work = gateway
1303 .check_and_record(&ctx, "read_file", &serde_json::json!({"path": "x.txt"}))
1304 .await;
1305 assert!(work.decision.is_allowed());
1306
1307 let wait = gateway
1310 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1311 .await;
1312 assert!(wait.decision.is_allowed(), "wait must bypass the per-turn work budget");
1313
1314 let denied = gateway
1316 .check_and_record(&ctx, "read_file", &serde_json::json!({"path": "y.txt"}))
1317 .await;
1318 assert!(denied.decision.is_denied());
1319 }
1320
1321 #[tokio::test]
1322 async fn control_plane_calls_are_bounded_by_their_own_cap() {
1323 let config = SafetyGatewayConfig {
1324 max_per_turn: 1_000,
1325 enforce_rate_limits: false,
1326 ..Default::default()
1327 };
1328 let gateway = SafetyGateway::with_config(config);
1329 let ctx = make_ctx();
1330
1331 for _ in 0..MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
1332 let call = gateway
1333 .check_and_record(
1334 &ctx,
1335 tools::UNIFIED_EXEC,
1336 &serde_json::json!({"action": "wait", "session_id": "run-1"}),
1337 )
1338 .await;
1339 assert!(call.decision.is_allowed());
1340 }
1341
1342 let overflow = gateway
1344 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1345 .await;
1346 assert!(overflow.decision.is_denied());
1347 assert!(overflow.decision.reason().is_some_and(|reason| reason.contains("limit")));
1348 }
1349
1350 #[tokio::test]
1351 async fn control_plane_calls_still_consume_session_and_rate_budget() {
1352 let config = SafetyGatewayConfig {
1354 max_per_turn: 1_000,
1355 max_per_session: 2,
1356 enforce_rate_limits: false,
1357 ..Default::default()
1358 };
1359 let gateway = SafetyGateway::with_config(config);
1360 let ctx = make_ctx();
1361
1362 for _ in 0..2 {
1363 let call = gateway
1364 .check_and_record(
1365 &ctx,
1366 tools::UNIFIED_EXEC,
1367 &serde_json::json!({"action": "wait", "session_id": "run-1"}),
1368 )
1369 .await;
1370 assert!(call.decision.is_allowed());
1371 }
1372 let third = gateway
1373 .check_and_record(&ctx, tools::UNIFIED_EXEC, &serde_json::json!({"action": "wait", "session_id": "run-1"}))
1374 .await;
1375 assert!(third.decision.is_denied(), "session fuse must bound control-plane calls");
1376 }
1377
1378 #[tokio::test]
1379 async fn start_turn_resets_control_plane_counter() {
1380 let config = SafetyGatewayConfig {
1381 max_per_turn: 1_000,
1382 enforce_rate_limits: false,
1383 ..Default::default()
1384 };
1385 let gateway = SafetyGateway::with_config(config);
1386 let ctx = make_ctx();
1387 let wait_args = serde_json::json!({"action": "wait", "session_id": "run-1"});
1388
1389 for _ in 0..MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN {
1390 assert!(
1391 gateway
1392 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1393 .await
1394 .decision
1395 .is_allowed()
1396 );
1397 }
1398 assert!(
1399 gateway
1400 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1401 .await
1402 .decision
1403 .is_denied()
1404 );
1405
1406 gateway.start_turn();
1407
1408 assert!(
1409 gateway
1410 .check_and_record(&ctx, tools::UNIFIED_EXEC, &wait_args)
1411 .await
1412 .decision
1413 .is_allowed(),
1414 "a fresh turn must refill the control-plane bucket"
1415 );
1416 }
1417}