1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::config::AgentMode;
7use crate::policy::{RunPolicy, ToolPolicyDecision as RunToolDecision, WritePolicyDecision};
8use crate::workflow::{AutonomyMode, RiskLevel, WorkflowContract, WorkflowType, WorkspaceScope};
9use crate::{guardrails::GuardrailLevel, hooks::HookResult, trust::Provenance};
10
11#[derive(Debug, Clone, Default)]
16pub struct ReferenceMonitor;
17
18impl ReferenceMonitor {
19 pub fn check_tool_action(
20 &self,
21 context: &ToolPolicyContext,
22 run_policy: &RunPolicy,
23 ) -> ToolPolicyDecision {
24 if !context.mode.allows_tool(&context.tool_name) {
25 return ToolPolicyDecision::Deny {
26 reason: PolicyReason::new(
27 PolicySource::AgentMode,
28 "agent_mode_tool_denied",
29 format!(
30 "Tool `{}` is not available in {:?} mode.",
31 context.tool_name, context.mode
32 ),
33 ),
34 };
35 }
36
37 match run_policy.check_tool(&context.tool_name) {
38 RunToolDecision::Allowed => {}
39 RunToolDecision::Denied(message) => {
40 return ToolPolicyDecision::Deny {
41 reason: PolicyReason::new(
42 PolicySource::RunPolicy,
43 "run_policy_tool_denied",
44 message,
45 ),
46 };
47 }
48 }
49
50 if context.metadata.workspace_write
51 || matches!(
52 context.action_kind,
53 ToolActionKind::Write | ToolActionKind::Edit
54 )
55 {
56 if let (Some(cwd), Some(path)) = (context.cwd.as_deref(), context.resource_scope.path())
57 {
58 match run_policy.check_write_path(cwd, path) {
59 WritePolicyDecision::Allowed => {}
60 WritePolicyDecision::Denied(message) => {
61 return ToolPolicyDecision::Deny {
62 reason: PolicyReason::new(
63 PolicySource::RunPolicy,
64 "run_policy_write_path_denied",
65 message,
66 ),
67 };
68 }
69 }
70 }
71 }
72
73 let trust_decision = self.check_trust_escalation(context);
74 if !trust_decision.is_allowed() {
75 return trust_decision;
76 }
77
78 let autonomy_decision = self.check_autonomy(context);
79 if !autonomy_decision.is_allowed() {
80 return autonomy_decision;
81 }
82
83 ToolPolicyDecision::allow()
84 }
85
86 pub fn record(
87 &self,
88 context: &ToolPolicyContext,
89 decision: ToolPolicyDecision,
90 details: Value,
91 ) -> PolicyTraceRecord {
92 let mut record = PolicyTraceRecord::from_context(context, decision);
93 record.details = details;
94 record
95 }
96
97 pub fn ask_user_record(
98 &self,
99 context: &ToolPolicyContext,
100 message: impl Into<String>,
101 ) -> PolicyTraceRecord {
102 self.record(
103 context,
104 ToolPolicyDecision::AskUser {
105 reason: PolicyReason::new(
106 PolicySource::WorkflowAutonomy,
107 "ask_user_required",
108 message.into(),
109 ),
110 },
111 serde_json::json!({ "unsupported_decision": "ask_user" }),
112 )
113 }
114
115 pub fn dry_run_only_record(
116 &self,
117 context: &ToolPolicyContext,
118 message: impl Into<String>,
119 ) -> PolicyTraceRecord {
120 self.record(
121 context,
122 ToolPolicyDecision::DryRunOnly {
123 reason: PolicyReason::new(
124 PolicySource::ToolManifest,
125 "dry_run_required",
126 message.into(),
127 ),
128 },
129 serde_json::json!({ "unsupported_decision": "dry_run_only" }),
130 )
131 }
132
133 pub fn sandbox_only_record(
134 &self,
135 context: &ToolPolicyContext,
136 message: impl Into<String>,
137 ) -> PolicyTraceRecord {
138 self.record(
139 context,
140 ToolPolicyDecision::SandboxOnly {
141 reason: PolicyReason::new(
142 PolicySource::ToolManifest,
143 "sandbox_required",
144 message.into(),
145 ),
146 },
147 serde_json::json!({ "unsupported_decision": "sandbox_only" }),
148 )
149 }
150
151 pub fn require_verification_record(
152 &self,
153 context: &ToolPolicyContext,
154 message: impl Into<String>,
155 ) -> PolicyTraceRecord {
156 self.record(
157 context,
158 ToolPolicyDecision::RequireVerification {
159 reason: PolicyReason::new(
160 PolicySource::WorkflowAutonomy,
161 "require_verification",
162 message.into(),
163 ),
164 },
165 serde_json::json!({ "unsupported_decision": "require_verification" }),
166 )
167 }
168
169 pub fn hook_blocked_record(
170 &self,
171 context: &ToolPolicyContext,
172 hook: &HookResult,
173 ) -> PolicyTraceRecord {
174 self.record(
175 context,
176 ToolPolicyDecision::Deny {
177 reason: PolicyReason::new(
178 PolicySource::Hook,
179 "hook_blocked",
180 hook.reason
181 .clone()
182 .unwrap_or_else(|| "Hook blocked tool execution".into()),
183 ),
184 },
185 serde_json::json!({ "hook": { "reason": hook.reason, "block": hook.block } }),
186 )
187 }
188
189 pub fn mana_policy_record(
190 &self,
191 context: &ToolPolicyContext,
192 decision: &crate::agent::ManaPolicyDecision,
193 ) -> PolicyTraceRecord {
194 let policy_decision = if decision.allowed {
195 ToolPolicyDecision::Allow {
196 reasons: vec![PolicyReason::new(
197 PolicySource::ManaLoop,
198 "mana_policy_allowed",
199 "Mana action allowed by active mode",
200 )],
201 }
202 } else {
203 ToolPolicyDecision::Deny {
204 reason: PolicyReason::new(
205 PolicySource::ManaLoop,
206 "mana_policy_blocked",
207 decision
208 .reason
209 .clone()
210 .unwrap_or_else(|| "Mana action blocked by active mode".into()),
211 ),
212 }
213 };
214 self.record(context, policy_decision, decision.details())
215 }
216
217 pub fn bash_equivalent_record(
218 &self,
219 context: &ToolPolicyContext,
220 hint: &str,
221 ) -> PolicyTraceRecord {
222 let mut reason = PolicyReason::new(
223 PolicySource::BashEquivalent,
224 "policy_blocked",
225 hint.to_string(),
226 );
227 reason.suggestion = Some("Use the native mana tool instead of shelling out to mana".into());
228 self.record(
229 context,
230 ToolPolicyDecision::Deny { reason },
231 serde_json::json!({ "bash_equivalent_hint": hint }),
232 )
233 }
234
235 pub fn repeated_call_record(
236 &self,
237 context: &ToolPolicyContext,
238 blocked: bool,
239 message: &str,
240 ) -> PolicyTraceRecord {
241 self.record(
242 context,
243 if blocked {
244 ToolPolicyDecision::Deny {
245 reason: PolicyReason::new(
246 PolicySource::RepeatedCall,
247 "repeated_tool_call_blocked",
248 message,
249 ),
250 }
251 } else {
252 ToolPolicyDecision::Allow {
253 reasons: vec![PolicyReason::new(
254 PolicySource::RepeatedCall,
255 "repeated_tool_call_warned",
256 message,
257 )],
258 }
259 },
260 serde_json::json!({ "repeated_call": { "blocked": blocked, "message": message } }),
261 )
262 }
263
264 pub fn validation_error_record(
265 &self,
266 context: &ToolPolicyContext,
267 message: &str,
268 ) -> PolicyTraceRecord {
269 self.record(
270 context,
271 ToolPolicyDecision::Deny {
272 reason: PolicyReason::new(PolicySource::Schema, "validation_error", message),
273 },
274 serde_json::json!({ "validation_error": message }),
275 )
276 }
277
278 pub fn dangerous_grant_required_record(
279 &self,
280 context: &ToolPolicyContext,
281 rail: DangerousRail,
282 ) -> PolicyTraceRecord {
283 self.record(
284 context,
285 ToolPolicyDecision::Deny {
286 reason: PolicyReason::new(
287 PolicySource::DangerousGrant,
288 rail.reason_code(),
289 rail.message(),
290 ),
291 },
292 serde_json::json!({ "dangerous_rail": rail }),
293 )
294 }
295
296 pub fn guardrail_record(
297 &self,
298 context: &ToolPolicyContext,
299 level: GuardrailLevel,
300 failed: bool,
301 message: &str,
302 ) -> PolicyTraceRecord {
303 let decision = if failed && matches!(level, GuardrailLevel::Enforce) {
304 ToolPolicyDecision::Deny {
305 reason: PolicyReason::new(PolicySource::Guardrail, "guardrail_enforced", message),
306 }
307 } else {
308 ToolPolicyDecision::Allow {
309 reasons: vec![PolicyReason::new(
310 PolicySource::Guardrail,
311 if failed {
312 "guardrail_advisory_failed"
313 } else {
314 "guardrail_passed"
315 },
316 message,
317 )],
318 }
319 };
320 self.record(
321 context,
322 decision,
323 serde_json::json!({ "guardrail": { "level": format!("{level:?}"), "failed": failed, "message": message } }),
324 )
325 }
326 pub fn evaluate(
327 &self,
328 context: &ToolPolicyContext,
329 run_policy: &RunPolicy,
330 ) -> PolicyTraceRecord {
331 let decision = self.check_tool_action(context, run_policy);
332 PolicyTraceRecord::from_context(context, decision)
333 }
334
335 fn check_trust_escalation(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
336 if context.supporting_provenance.is_empty() {
337 return ToolPolicyDecision::allow();
338 }
339 if context
340 .supporting_provenance
341 .iter()
342 .any(|provenance| !provenance.is_low_trust())
343 {
344 return ToolPolicyDecision::allow();
345 }
346 if !context.is_high_risk_action() {
347 return ToolPolicyDecision::allow();
348 }
349
350 let source_summary = context
351 .supporting_provenance
352 .iter()
353 .filter_map(|provenance| provenance.origin.as_deref())
354 .collect::<Vec<_>>()
355 .join(", ");
356 let mut reason = PolicyReason::new(
357 PolicySource::TrustLabel,
358 "low_trust_escalation_denied",
359 if source_summary.is_empty() {
360 "Low-trust context cannot authorize this high-risk action.".to_string()
361 } else {
362 format!(
363 "Low-trust context cannot authorize this high-risk action. Source: {source_summary}"
364 )
365 },
366 );
367 reason.suggestion = Some(
368 "Ask the user to explicitly authorize the action or provide trusted workflow policy."
369 .into(),
370 );
371 if context.action_kind == ToolActionKind::Network {
372 ToolPolicyDecision::AskUser { reason }
373 } else {
374 ToolPolicyDecision::Deny { reason }
375 }
376 }
377
378 fn check_autonomy(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
379 use AutonomyMode::*;
380 match context.autonomy_mode {
381 Suggest => match context.action_kind {
382 ToolActionKind::Read | ToolActionKind::Search | ToolActionKind::AskUser => {
383 ToolPolicyDecision::allow()
384 }
385 _ => ToolPolicyDecision::Deny {
386 reason: PolicyReason::new(
387 PolicySource::WorkflowAutonomy,
388 "autonomy_suggest_side_effect_denied",
389 "Suggest mode does not execute side-effecting tools.",
390 ),
391 },
392 },
393 Safe => ToolPolicyDecision::allow(),
394 LocalAuto | WorktreeAuto => self.check_local_auto(context),
395 AllowAllLocal => self.check_allow_all_local(context),
396 AllowAll => self.check_allow_all(context),
397 Ci => self.check_ci(context),
398 }
399 }
400
401 fn check_local_auto(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
402 if context.metadata.secrets || context.action_kind == ToolActionKind::Secret {
403 return ToolPolicyDecision::Deny {
404 reason: PolicyReason::new(
405 PolicySource::WorkflowAutonomy,
406 "autonomy_secret_denied",
407 "Autonomy modes cannot reveal or directly access secrets.",
408 ),
409 };
410 }
411 if matches!(context.resource_scope, ResourceScope::Network { .. })
412 || context.metadata.network
413 {
414 return self.ask_user_decision(
415 "autonomy_network_requires_approval",
416 "Network actions require approval in local-auto mode.",
417 );
418 }
419 if self.is_outside_workspace(context) {
420 return ToolPolicyDecision::Deny {
421 reason: PolicyReason::new(
422 PolicySource::WorkflowAutonomy,
423 "autonomy_outside_workspace_denied",
424 "Autonomous writes outside the workspace are denied.",
425 ),
426 };
427 }
428 if context.autonomy_mode == AutonomyMode::WorktreeAuto
429 && !matches!(context.workspace_scope, WorkspaceScope::Worktree { .. })
430 {
431 return ToolPolicyDecision::SandboxOnly {
432 reason: PolicyReason::new(
433 PolicySource::WorkflowAutonomy,
434 "autonomy_worktree_required",
435 "worktree-auto requires an isolated worktree. Worktree execution lands in 394.9; run in an existing worktree context or choose local-auto/safe for current-workspace execution.",
436 ),
437 };
438 }
439 ToolPolicyDecision::allow()
440 }
441
442 fn check_allow_all_local(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
443 if context.metadata.secrets || context.action_kind == ToolActionKind::Secret {
444 return ToolPolicyDecision::Deny {
445 reason: PolicyReason::new(
446 PolicySource::WorkflowAutonomy,
447 "autonomy_secret_denied",
448 "Allow-all modes still deny secret reveal or direct secret access.",
449 ),
450 };
451 }
452 if context.metadata.network
453 || matches!(context.resource_scope, ResourceScope::Network { .. })
454 {
455 return self.ask_user_decision(
456 "autonomy_network_requires_approval",
457 "Network actions require approval in allow-all-local mode.",
458 );
459 }
460 if self.is_outside_workspace(context) {
461 return ToolPolicyDecision::Deny {
462 reason: PolicyReason::new(
463 PolicySource::WorkflowAutonomy,
464 "autonomy_outside_workspace_denied",
465 "allow-all-local is scoped to the workspace/worktree.",
466 ),
467 };
468 }
469 ToolPolicyDecision::allow()
470 }
471
472 fn check_allow_all(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
473 if context.metadata.secrets || context.action_kind == ToolActionKind::Secret {
474 return ToolPolicyDecision::Deny {
475 reason: PolicyReason::new(
476 PolicySource::WorkflowAutonomy,
477 "autonomy_secret_denied",
478 "Allow-all still denies secret reveal or direct secret access.",
479 ),
480 };
481 }
482 if self.is_outside_workspace(context) && context.metadata.workspace_write {
483 return self.ask_user_decision(
484 "autonomy_outside_workspace_requires_approval",
485 "Outside-workspace writes require explicit approval in allow-all mode.",
486 );
487 }
488 ToolPolicyDecision::allow()
489 }
490
491 fn check_ci(&self, context: &ToolPolicyContext) -> ToolPolicyDecision {
492 if context.metadata.secrets || context.action_kind == ToolActionKind::Secret {
493 return ToolPolicyDecision::Deny {
494 reason: PolicyReason::new(
495 PolicySource::WorkflowAutonomy,
496 "autonomy_secret_denied",
497 "CI mode cannot reveal or directly access secrets.",
498 ),
499 };
500 }
501 if context.metadata.network
502 || matches!(context.resource_scope, ResourceScope::Network { .. })
503 {
504 return ToolPolicyDecision::Deny {
505 reason: PolicyReason::new(
506 PolicySource::WorkflowAutonomy,
507 "autonomy_ci_network_denied",
508 "CI mode denies network actions unless future trusted configuration grants them.",
509 ),
510 };
511 }
512 if context.metadata.requires_approval || context.metadata.default_requires_approval {
513 return ToolPolicyDecision::Deny {
514 reason: PolicyReason::new(
515 PolicySource::WorkflowAutonomy,
516 "autonomy_ci_approval_denied",
517 "CI mode fails closed when an action would require approval.",
518 ),
519 };
520 }
521 if self.is_outside_workspace(context) {
522 return ToolPolicyDecision::Deny {
523 reason: PolicyReason::new(
524 PolicySource::WorkflowAutonomy,
525 "autonomy_outside_workspace_denied",
526 "CI mode denies outside-workspace writes.",
527 ),
528 };
529 }
530 ToolPolicyDecision::allow()
531 }
532
533 fn ask_user_decision(&self, code: &'static str, message: &'static str) -> ToolPolicyDecision {
534 ToolPolicyDecision::AskUser {
535 reason: PolicyReason::new(PolicySource::WorkflowAutonomy, code, message),
536 }
537 }
538
539 fn is_outside_workspace(&self, context: &ToolPolicyContext) -> bool {
540 let Some(cwd) = context.cwd.as_deref() else {
541 return false;
542 };
543 let Some(path) = context.resource_scope.path() else {
544 return false;
545 };
546 !path.starts_with(cwd)
547 }
548}
549
550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
552pub struct ToolPolicyContext {
553 pub run_id: Option<String>,
554 pub workflow_id: Option<String>,
555 pub turn: Option<u32>,
556 pub tool_call_id: Option<String>,
557 pub tool_name: String,
558 pub action_kind: ToolActionKind,
559 pub args: Value,
560 pub args_hash: Option<String>,
561 pub cwd: Option<PathBuf>,
562 pub resource_scope: ResourceScope,
563 pub mode: AgentMode,
564 pub autonomy_mode: AutonomyMode,
565 pub workflow_type: WorkflowType,
566 pub risk_level: RiskLevel,
567 pub workspace_scope: WorkspaceScope,
568 pub trust_scope: TrustScopeContext,
569 pub trust_labels: Vec<String>,
570 pub supporting_provenance: Vec<Provenance>,
571 pub metadata: ToolMetadata,
572}
573
574impl ToolPolicyContext {
575 pub fn new(tool_name: impl Into<String>, action_kind: ToolActionKind) -> Self {
576 let tool_name = tool_name.into();
577 Self {
578 metadata: ToolMetadata::new(tool_name.clone(), action_kind),
579 run_id: None,
580 workflow_id: None,
581 turn: None,
582 tool_call_id: None,
583 tool_name,
584 action_kind,
585 args: Value::Null,
586 args_hash: None,
587 cwd: None,
588 resource_scope: ResourceScope::default(),
589 mode: AgentMode::default(),
590 autonomy_mode: AutonomyMode::default(),
591 workflow_type: WorkflowType::default(),
592 risk_level: RiskLevel::default(),
593 workspace_scope: WorkspaceScope::default(),
594 trust_scope: TrustScopeContext::default(),
595 trust_labels: Vec::new(),
596 supporting_provenance: Vec::new(),
597 }
598 }
599 pub fn apply_workflow_contract(&mut self, contract: &WorkflowContract) {
600 self.workflow_id = contract
601 .id
602 .clone()
603 .or_else(|| contract.mana_unit_ref.clone());
604 self.autonomy_mode = contract.autonomy_mode;
605 self.workflow_type = contract.workflow_type;
606 self.risk_level = contract.risk_level;
607 self.workspace_scope = contract.workspace_scope.clone();
608 self.trust_scope = TrustScopeContext::from_contract(contract);
609 self.trust_labels = self.trust_scope.labels();
610 }
611
612 pub fn with_workflow_contract(mut self, contract: &WorkflowContract) -> Self {
613 self.apply_workflow_contract(contract);
614 self
615 }
616 pub fn with_supporting_provenance(mut self, provenance: Provenance) -> Self {
617 self.supporting_provenance.push(provenance);
618 self
619 }
620
621 fn is_high_risk_action(&self) -> bool {
622 self.metadata.workspace_write
623 || self.metadata.external_side_effect
624 || self.metadata.network
625 || self.metadata.secrets
626 || matches!(
627 self.action_kind,
628 ToolActionKind::Write
629 | ToolActionKind::Edit
630 | ToolActionKind::Execute
631 | ToolActionKind::Network
632 | ToolActionKind::Git
633 | ToolActionKind::Mana
634 | ToolActionKind::Secret
635 | ToolActionKind::Extension
636 )
637 || self.resource_scope.path().is_some_and(|path| {
638 self.cwd
639 .as_deref()
640 .is_some_and(|cwd| !path.starts_with(cwd))
641 })
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(default)]
647pub struct TrustScopeContext {
648 pub allow_external_context: bool,
649 pub allow_durable_memory_writes: bool,
650 pub low_trust_requires_review: bool,
651}
652
653impl TrustScopeContext {
654 pub fn from_contract(contract: &WorkflowContract) -> Self {
655 Self {
656 allow_external_context: contract.trust_scope.allow_external_context,
657 allow_durable_memory_writes: contract.trust_scope.allow_durable_memory_writes,
658 low_trust_requires_review: contract.trust_scope.low_trust_requires_review,
659 }
660 }
661
662 pub fn labels(&self) -> Vec<String> {
663 let mut labels = Vec::new();
664 labels.push(
665 if self.allow_external_context {
666 "external-context-allowed"
667 } else {
668 "external-context-blocked"
669 }
670 .to_string(),
671 );
672 labels.push(
673 if self.allow_durable_memory_writes {
674 "durable-memory-writes-allowed"
675 } else {
676 "durable-memory-writes-blocked"
677 }
678 .to_string(),
679 );
680 labels.push(
681 if self.low_trust_requires_review {
682 "low-trust-review-required"
683 } else {
684 "low-trust-review-not-required"
685 }
686 .to_string(),
687 );
688 labels
689 }
690}
691
692impl Default for TrustScopeContext {
693 fn default() -> Self {
694 Self {
695 allow_external_context: true,
696 allow_durable_memory_writes: true,
697 low_trust_requires_review: true,
698 }
699 }
700}
701
702#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
704#[serde(rename_all = "snake_case")]
705pub enum ToolActionKind {
706 Read,
707 Write,
708 Edit,
709 Execute,
710 Search,
711 Network,
712 Git,
713 Mana,
714 AskUser,
715 Secret,
716 Extension,
717 #[default]
718 Unknown,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct ToolMetadata {
724 pub name: String,
725 pub action_kind: ToolActionKind,
726 pub readonly: bool,
727 pub workspace_write: bool,
728 pub external_side_effect: bool,
729 pub network: bool,
730 pub secrets: bool,
731 pub extension: bool,
732 pub default_requires_approval: bool,
733 pub resource_scopes: Vec<ResourceScope>,
734 pub supports_dry_run: bool,
735 pub supports_sandbox: bool,
736 pub requires_approval: bool,
737 pub extension_id: Option<String>,
738 pub manifest_version: Option<String>,
739}
740
741impl ToolMetadata {
742 pub fn new(name: impl Into<String>, action_kind: ToolActionKind) -> Self {
743 Self {
744 name: name.into(),
745 action_kind,
746 readonly: matches!(
747 action_kind,
748 ToolActionKind::Read | ToolActionKind::Search | ToolActionKind::AskUser
749 ),
750 workspace_write: matches!(action_kind, ToolActionKind::Write | ToolActionKind::Edit),
751 external_side_effect: matches!(
752 action_kind,
753 ToolActionKind::Execute
754 | ToolActionKind::Network
755 | ToolActionKind::Git
756 | ToolActionKind::Mana
757 | ToolActionKind::Secret
758 | ToolActionKind::Extension
759 ),
760 network: matches!(action_kind, ToolActionKind::Network),
761 secrets: matches!(action_kind, ToolActionKind::Secret),
762 extension: matches!(action_kind, ToolActionKind::Extension),
763 default_requires_approval: false,
764 resource_scopes: Vec::new(),
765 supports_dry_run: false,
766 supports_sandbox: false,
767 requires_approval: false,
768 extension_id: None,
769 manifest_version: None,
770 }
771 }
772
773 pub fn resource_scope_for_args(
774 &self,
775 cwd: Option<&std::path::Path>,
776 args: &Value,
777 ) -> ResourceScope {
778 let path_arg = args
779 .get("path")
780 .or_else(|| args.get("file"))
781 .or_else(|| args.get("directory"))
782 .and_then(Value::as_str);
783 if let Some(path) = path_arg {
784 let path = PathBuf::from(path);
785 let path = match cwd {
786 Some(cwd) if path.is_relative() => cwd.join(path),
787 _ => path,
788 };
789 return match self.action_kind {
790 ToolActionKind::Search => ResourceScope::Directory { path },
791 _ => ResourceScope::File { path },
792 };
793 }
794 if self.action_kind == ToolActionKind::Execute {
795 if let Some(command) = args.get("command").and_then(Value::as_str) {
796 let program = command
797 .split_whitespace()
798 .next()
799 .unwrap_or(command)
800 .to_string();
801 return ResourceScope::Command { program };
802 }
803 }
804 if self.action_kind == ToolActionKind::Mana {
805 return ResourceScope::Mana {
806 action: args
807 .get("action")
808 .and_then(Value::as_str)
809 .map(str::to_string),
810 };
811 }
812 if self.action_kind == ToolActionKind::Network {
813 return ResourceScope::Network {
814 host: args
815 .get("url")
816 .and_then(Value::as_str)
817 .and_then(extract_host),
818 };
819 }
820 self.resource_scopes
821 .first()
822 .cloned()
823 .unwrap_or(ResourceScope::None)
824 }
825
826 pub fn for_tool_name(name: impl Into<String>, readonly: bool) -> Self {
827 let name = name.into();
828 let mut metadata = Self::new(name.clone(), ToolActionKind::from_tool_name(&name));
829 metadata.readonly = readonly || metadata.readonly;
830 match name.as_str() {
831 "read" => {
832 metadata.resource_scopes.push(ResourceScope::File {
833 path: PathBuf::new(),
834 });
835 }
836 "write" | "edit" | "multi_edit" => {
837 metadata.workspace_write = true;
838 metadata.resource_scopes.push(ResourceScope::File {
839 path: PathBuf::new(),
840 });
841 }
842 "bash" => {
843 metadata.external_side_effect = true;
844 metadata.resource_scopes.push(ResourceScope::Command {
845 program: String::new(),
846 });
847 }
848 "git" => {
849 metadata.external_side_effect = true;
850 metadata.workspace_write = true;
851 }
852 "mana" => {
853 metadata.external_side_effect = true;
854 metadata
855 .resource_scopes
856 .push(ResourceScope::Mana { action: None });
857 }
858 "web" => {
859 metadata.network = true;
860 metadata.external_side_effect = true;
861 metadata
862 .resource_scopes
863 .push(ResourceScope::Network { host: None });
864 }
865 "extend" => {
866 metadata.workspace_write = true;
867 metadata.extension = true;
868 metadata.external_side_effect = true;
869 }
870 name if name.starts_with("lua:") || name.starts_with("extension:") => {
871 metadata.extension = true;
872 metadata.extension_id = Some(name.to_string());
873 metadata.external_side_effect = !metadata.readonly;
874 }
875 _ => {}
876 }
877 metadata.default_requires_approval = metadata.external_side_effect && !metadata.readonly;
878 metadata
879 }
880}
881
882impl ToolActionKind {
883 pub fn from_tool_name(name: &str) -> Self {
884 match name {
885 "read" => Self::Read,
886 "scan" | "search" | "session_search" | "memory" => Self::Search,
887 "write" => Self::Write,
888 "edit" | "multi_edit" => Self::Edit,
889 "bash" | "shell" => Self::Execute,
890 "git" | "worktree" => Self::Git,
891 "mana" => Self::Mana,
892 "web" => Self::Network,
893 "ask" | "ask_user" => Self::AskUser,
894 "extend" => Self::Extension,
895 name if name.starts_with("lua:") || name.starts_with("extension:") => Self::Extension,
896 _ => Self::Unknown,
897 }
898 }
899}
900
901impl Default for ToolMetadata {
902 fn default() -> Self {
903 Self::new("unknown", ToolActionKind::Unknown)
904 }
905}
906
907#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
909#[serde(tag = "kind", rename_all = "snake_case")]
910pub enum ResourceScope {
911 #[default]
912 None,
913 File {
914 path: PathBuf,
915 },
916 Directory {
917 path: PathBuf,
918 },
919 Command {
920 program: String,
921 },
922 Network {
923 host: Option<String>,
924 },
925 Mana {
926 action: Option<String>,
927 },
928 Secret {
929 name: Option<String>,
930 },
931 Extension {
932 id: String,
933 },
934}
935
936impl ResourceScope {
937 pub fn path(&self) -> Option<&std::path::Path> {
938 match self {
939 ResourceScope::File { path } | ResourceScope::Directory { path } => {
940 Some(path.as_path())
941 }
942 _ => None,
943 }
944 }
945}
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
948#[serde(rename_all = "snake_case")]
949pub enum DangerousRail {
950 SecretExfiltration,
951 PrivateKeyRead,
952 OutsideWorkspaceDestructiveWrite,
953 ForcePush,
954 GlobalGitConfigMutation,
955 ProductionDeploy,
956 CloudResourceDeletion,
957 AuditLogDisable,
958}
959
960impl DangerousRail {
961 pub fn reason_code(self) -> &'static str {
962 match self {
963 Self::SecretExfiltration => "dangerous_secret_exfiltration",
964 Self::PrivateKeyRead => "dangerous_private_key_read",
965 Self::OutsideWorkspaceDestructiveWrite => {
966 "dangerous_outside_workspace_destructive_write"
967 }
968 Self::ForcePush => "dangerous_force_push",
969 Self::GlobalGitConfigMutation => "dangerous_global_git_config_mutation",
970 Self::ProductionDeploy => "dangerous_production_deploy",
971 Self::CloudResourceDeletion => "dangerous_cloud_resource_deletion",
972 Self::AuditLogDisable => "dangerous_audit_log_disable",
973 }
974 }
975
976 pub fn message(self) -> &'static str {
977 match self {
978 Self::SecretExfiltration => "Secret exfiltration requires an explicit dangerous grant.",
979 Self::PrivateKeyRead => "Reading private keys requires an explicit dangerous grant.",
980 Self::OutsideWorkspaceDestructiveWrite => {
981 "Destructive writes outside the workspace require an explicit dangerous grant."
982 }
983 Self::ForcePush => "Force-push requires an explicit dangerous grant.",
984 Self::GlobalGitConfigMutation => {
985 "Global git config mutation requires an explicit dangerous grant."
986 }
987 Self::ProductionDeploy => "Production deploys require an explicit dangerous grant.",
988 Self::CloudResourceDeletion => {
989 "Cloud resource deletion requires an explicit dangerous grant."
990 }
991 Self::AuditLogDisable => "Disabling audit logs requires an explicit dangerous grant.",
992 }
993 }
994}
995
996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
998pub struct PolicyReason {
999 pub source: PolicySource,
1000 pub code: String,
1001 pub message: String,
1002 pub suggestion: Option<String>,
1003}
1004
1005impl PolicyReason {
1006 pub fn new(source: PolicySource, code: impl Into<String>, message: impl Into<String>) -> Self {
1007 Self {
1008 source,
1009 code: code.into(),
1010 message: message.into(),
1011 suggestion: None,
1012 }
1013 }
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1017#[serde(rename_all = "snake_case")]
1018pub enum PolicySource {
1019 AgentMode,
1020 RunPolicy,
1021 ManaLoop,
1022 BashEquivalent,
1023 RepeatedCall,
1024 Hook,
1025 Schema,
1026 Guardrail,
1027 WorkflowAutonomy,
1028 TrustLabel,
1029 ToolManifest,
1030 DangerousGrant,
1031 Unknown,
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1036#[serde(tag = "decision", rename_all = "snake_case")]
1037pub enum ToolPolicyDecision {
1038 Allow { reasons: Vec<PolicyReason> },
1039 Deny { reason: PolicyReason },
1040 AskUser { reason: PolicyReason },
1041 DryRunOnly { reason: PolicyReason },
1042 SandboxOnly { reason: PolicyReason },
1043 RequireVerification { reason: PolicyReason },
1044}
1045
1046impl ToolPolicyDecision {
1047 pub fn allow() -> Self {
1048 Self::Allow {
1049 reasons: Vec::new(),
1050 }
1051 }
1052
1053 pub fn is_allowed(&self) -> bool {
1054 matches!(self, Self::Allow { .. })
1055 }
1056}
1057
1058impl Default for ToolPolicyDecision {
1059 fn default() -> Self {
1060 Self::allow()
1061 }
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1066pub struct PolicyTraceRecord {
1067 pub run_id: Option<String>,
1068 pub workflow_id: Option<String>,
1069 pub turn: Option<u32>,
1070 pub tool_call_id: Option<String>,
1071 pub tool_name: String,
1072 pub action_kind: ToolActionKind,
1073 pub decision: ToolPolicyDecision,
1074 pub args_hash: Option<String>,
1075 pub resource_scope: ResourceScope,
1076 pub autonomy_mode: AutonomyMode,
1077 pub workflow_type: WorkflowType,
1078 pub risk_level: RiskLevel,
1079 pub trust_scope: TrustScopeContext,
1080 pub trust_labels: Vec<String>,
1081 pub details: Value,
1082}
1083
1084impl PolicyTraceRecord {
1085 pub fn from_context(context: &ToolPolicyContext, decision: ToolPolicyDecision) -> Self {
1086 Self {
1087 run_id: context.run_id.clone(),
1088 workflow_id: context.workflow_id.clone(),
1089 turn: context.turn,
1090 tool_call_id: context.tool_call_id.clone(),
1091 tool_name: context.tool_name.clone(),
1092 action_kind: context.action_kind,
1093 decision,
1094 args_hash: context.args_hash.clone(),
1095 resource_scope: context.resource_scope.clone(),
1096 autonomy_mode: context.autonomy_mode,
1097 workflow_type: context.workflow_type,
1098 risk_level: context.risk_level,
1099 trust_scope: context.trust_scope.clone(),
1100 trust_labels: context.trust_labels.clone(),
1101 details: Value::Null,
1102 }
1103 }
1104 pub fn to_trace_event(&self, run_id: impl Into<String>) -> crate::trace::TraceEvent {
1105 let mut event = crate::trace::TraceEvent::new(
1106 run_id,
1107 "policy.checked",
1108 serde_json::json!({
1109 "tool_name": self.tool_name,
1110 "action_kind": self.action_kind,
1111 "decision": self.decision,
1112 "resource_scope": self.resource_scope_summary(),
1113 "args_hash": self.args_hash,
1114 "autonomy_mode": self.autonomy_mode,
1115 "workflow_type": self.workflow_type,
1116 "risk_level": self.risk_level,
1117 "trust_scope": self.trust_scope,
1118 "trust_labels": self.trust_labels,
1119 "details": self.details,
1120 }),
1121 );
1122 event.workflow_id = self.workflow_id.clone();
1123 event.turn = self.turn;
1124 if let Some(tool_call_id) = &self.tool_call_id {
1125 event = event.with_tool_call_id(tool_call_id.clone());
1126 }
1127 event.redaction.contains_redactions = true;
1128 if self.args_hash.is_some() {
1129 event.redaction.content_hash = self.args_hash.clone();
1130 }
1131 event
1132 }
1133
1134 fn resource_scope_summary(&self) -> Value {
1135 match &self.resource_scope {
1136 ResourceScope::None => Value::Null,
1137 ResourceScope::File { path } => {
1138 serde_json::json!({ "kind": "file", "path": path.display().to_string() })
1139 }
1140 ResourceScope::Directory { path } => {
1141 serde_json::json!({ "kind": "directory", "path": path.display().to_string() })
1142 }
1143 ResourceScope::Command { program } => {
1144 serde_json::json!({ "kind": "command", "program": program })
1145 }
1146 ResourceScope::Network { host } => {
1147 serde_json::json!({ "kind": "network", "host": host })
1148 }
1149 ResourceScope::Mana { action } => {
1150 serde_json::json!({ "kind": "mana", "action": action })
1151 }
1152 ResourceScope::Secret { name } => serde_json::json!({ "kind": "secret", "name": name }),
1153 ResourceScope::Extension { id } => serde_json::json!({ "kind": "extension", "id": id }),
1154 }
1155 }
1156}
1157
1158fn extract_host(url: &str) -> Option<String> {
1159 let without_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
1160 without_scheme
1161 .split(['/', '?', '#'])
1162 .next()
1163 .filter(|host| !host.is_empty())
1164 .map(str::to_string)
1165}
1166
1167#[cfg(test)]
1168mod reference_monitor_types_tests {
1169 use super::*;
1170
1171 #[test]
1172 fn tool_metadata_classifies_native_tools() {
1173 let read = ToolMetadata::for_tool_name("read", true);
1174 assert_eq!(read.action_kind, ToolActionKind::Read);
1175 assert!(read.readonly);
1176 assert!(!read.workspace_write);
1177
1178 let write = ToolMetadata::for_tool_name("write", false);
1179 assert_eq!(write.action_kind, ToolActionKind::Write);
1180 assert!(write.workspace_write);
1181 assert!(!write.readonly);
1182
1183 let edit = ToolMetadata::for_tool_name("edit", false);
1184 assert_eq!(edit.action_kind, ToolActionKind::Edit);
1185 assert!(edit.workspace_write);
1186
1187 let bash = ToolMetadata::for_tool_name("bash", false);
1188 assert_eq!(bash.action_kind, ToolActionKind::Execute);
1189 assert!(bash.external_side_effect);
1190 assert!(bash.default_requires_approval);
1191
1192 let git = ToolMetadata::for_tool_name("git", false);
1193 assert_eq!(git.action_kind, ToolActionKind::Git);
1194 assert!(git.external_side_effect);
1195 assert!(git.workspace_write);
1196
1197 let mana = ToolMetadata::for_tool_name("mana", false);
1198 assert_eq!(mana.action_kind, ToolActionKind::Mana);
1199 assert!(mana.external_side_effect);
1200
1201 let web = ToolMetadata::for_tool_name("web", true);
1202 assert_eq!(web.action_kind, ToolActionKind::Network);
1203 assert!(web.network);
1204 }
1205
1206 #[test]
1207 fn tool_metadata_classifies_extension_placeholder() {
1208 let metadata = ToolMetadata::for_tool_name("lua:deploy", false);
1209 assert_eq!(metadata.action_kind, ToolActionKind::Extension);
1210 assert!(metadata.extension);
1211 assert_eq!(metadata.extension_id.as_deref(), Some("lua:deploy"));
1212 assert!(metadata.external_side_effect);
1213 }
1214
1215 #[test]
1216 fn tool_metadata_extracts_resource_scope_from_args() {
1217 let read = ToolMetadata::for_tool_name("read", true);
1218 let scope = read.resource_scope_for_args(
1219 Some(std::path::Path::new("/repo")),
1220 &serde_json::json!({ "path": "src/lib.rs" }),
1221 );
1222 assert_eq!(
1223 scope,
1224 ResourceScope::File {
1225 path: std::path::PathBuf::from("/repo/src/lib.rs")
1226 }
1227 );
1228
1229 let bash = ToolMetadata::for_tool_name("bash", false);
1230 assert_eq!(
1231 bash.resource_scope_for_args(None, &serde_json::json!({ "command": "cargo test" })),
1232 ResourceScope::Command {
1233 program: "cargo".into()
1234 }
1235 );
1236
1237 let mana = ToolMetadata::for_tool_name("mana", false);
1238 assert_eq!(
1239 mana.resource_scope_for_args(None, &serde_json::json!({ "action": "close" })),
1240 ResourceScope::Mana {
1241 action: Some("close".into())
1242 }
1243 );
1244 }
1245
1246 #[test]
1247 fn reference_monitor_matches_run_policy_tool_allow_and_deny() {
1248 let monitor = ReferenceMonitor;
1249 let allowed_policy = RunPolicy::new().allow_tool("read");
1250 let denied_policy = RunPolicy::new().deny_tool("bash");
1251
1252 let read = ToolPolicyContext::new("read", ToolActionKind::Read);
1253 assert!(monitor
1254 .check_tool_action(&read, &allowed_policy)
1255 .is_allowed());
1256
1257 let bash = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1258 let run_policy_decision = denied_policy.check_tool("bash");
1259 let monitor_decision = monitor.check_tool_action(&bash, &denied_policy);
1260 match (run_policy_decision, monitor_decision) {
1261 (RunToolDecision::Denied(expected), ToolPolicyDecision::Deny { reason }) => {
1262 assert_eq!(reason.source, PolicySource::RunPolicy);
1263 assert_eq!(reason.code, "run_policy_tool_denied");
1264 assert_eq!(reason.message, expected);
1265 }
1266 other => panic!("unexpected decisions: {other:?}"),
1267 }
1268 }
1269
1270 #[test]
1271 fn reference_monitor_applies_agent_mode_before_run_policy() {
1272 let monitor = ReferenceMonitor;
1273 let mut context = ToolPolicyContext::new("write", ToolActionKind::Write);
1274 context.mode = AgentMode::Reviewer;
1275 let decision = monitor.check_tool_action(&context, &RunPolicy::new().allow_tool("write"));
1276 match decision {
1277 ToolPolicyDecision::Deny { ref reason } => {
1278 assert_eq!(reason.source, PolicySource::AgentMode);
1279 assert_eq!(reason.code, "agent_mode_tool_denied");
1280 }
1281 other => panic!("expected deny, got {other:?}"),
1282 }
1283 }
1284
1285 #[test]
1286 fn reference_monitor_matches_run_policy_write_path() {
1287 let monitor = ReferenceMonitor;
1288 let policy = RunPolicy::new().allow_tool("write").allow_write("src/**");
1289 let cwd = std::path::PathBuf::from("/repo");
1290 let mut context = ToolPolicyContext::new("write", ToolActionKind::Write);
1291 context.cwd = Some(cwd.clone());
1292 context.metadata = ToolMetadata::for_tool_name("write", false);
1293 context.resource_scope = ResourceScope::File {
1294 path: std::path::PathBuf::from("/repo/README.md"),
1295 };
1296
1297 let write_policy_decision =
1298 policy.check_write_path(&cwd, std::path::Path::new("/repo/README.md"));
1299 let monitor_decision = monitor.check_tool_action(&context, &policy);
1300 match (write_policy_decision, monitor_decision) {
1301 (WritePolicyDecision::Denied(expected), ToolPolicyDecision::Deny { reason }) => {
1302 assert_eq!(reason.source, PolicySource::RunPolicy);
1303 assert_eq!(reason.code, "run_policy_write_path_denied");
1304 assert_eq!(reason.message, expected);
1305 }
1306 other => panic!("unexpected decisions: {other:?}"),
1307 }
1308
1309 context.resource_scope = ResourceScope::File {
1310 path: std::path::PathBuf::from("/repo/src/lib.rs"),
1311 };
1312 assert!(monitor.check_tool_action(&context, &policy).is_allowed());
1313 }
1314
1315 #[test]
1316 fn reference_monitor_evaluate_returns_trace_record() {
1317 let monitor = ReferenceMonitor;
1318 let mut context = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1319 context.run_id = Some("run_1".into());
1320 let record = monitor.evaluate(&context, &RunPolicy::new().deny_tool("bash"));
1321 assert_eq!(record.run_id.as_deref(), Some("run_1"));
1322 assert_eq!(record.tool_name, "bash");
1323 assert!(matches!(record.decision, ToolPolicyDecision::Deny { .. }));
1324 }
1325
1326 #[test]
1327 fn policy_trace_records_cover_scattered_policy_outcomes() {
1328 let monitor = ReferenceMonitor;
1329 let context = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1330
1331 let hook = crate::hooks::HookResult {
1332 block: true,
1333 reason: Some("blocked by hook".into()),
1334 modified_content: None,
1335 };
1336 assert_policy_record(
1337 monitor.hook_blocked_record(&context, &hook),
1338 PolicySource::Hook,
1339 "hook_blocked",
1340 );
1341
1342 assert_policy_record(
1343 monitor.bash_equivalent_record(&context, "use mana tool"),
1344 PolicySource::BashEquivalent,
1345 "policy_blocked",
1346 );
1347
1348 assert_policy_record(
1349 monitor.repeated_call_record(&context, true, "loop detected"),
1350 PolicySource::RepeatedCall,
1351 "repeated_tool_call_blocked",
1352 );
1353
1354 assert_policy_record(
1355 monitor.repeated_call_record(&context, false, "possible loop"),
1356 PolicySource::RepeatedCall,
1357 "repeated_tool_call_warned",
1358 );
1359
1360 assert_policy_record(
1361 monitor.validation_error_record(&context, "bad args"),
1362 PolicySource::Schema,
1363 "validation_error",
1364 );
1365
1366 assert_policy_record(
1367 monitor.guardrail_record(
1368 &context,
1369 crate::guardrails::GuardrailLevel::Enforce,
1370 true,
1371 "guardrail failed",
1372 ),
1373 PolicySource::Guardrail,
1374 "guardrail_enforced",
1375 );
1376 }
1377
1378 #[test]
1379 fn policy_trace_records_cover_mana_policy_outcomes() {
1380 let monitor = ReferenceMonitor;
1381 let mut context = ToolPolicyContext::new("mana", ToolActionKind::Mana);
1382 context.mode = AgentMode::Reviewer;
1383 let decision = crate::agent::evaluate_mana_policy(
1384 context.mode,
1385 &serde_json::json!({ "action": "close" }),
1386 );
1387 let record = monitor.mana_policy_record(&context, &decision);
1388 assert_policy_record(record, PolicySource::ManaLoop, "mana_policy_blocked");
1389 }
1390
1391 fn assert_policy_record(record: PolicyTraceRecord, source: PolicySource, code: &str) {
1392 match record.decision {
1393 ToolPolicyDecision::Allow { reasons } => {
1394 assert!(reasons
1395 .iter()
1396 .any(|reason| reason.source == source && reason.code == code));
1397 }
1398 ToolPolicyDecision::Deny { reason }
1399 | ToolPolicyDecision::AskUser { reason }
1400 | ToolPolicyDecision::DryRunOnly { reason }
1401 | ToolPolicyDecision::SandboxOnly { reason }
1402 | ToolPolicyDecision::RequireVerification { reason } => {
1403 assert_eq!(reason.source, source);
1404 assert_eq!(reason.code, code);
1405 }
1406 }
1407 }
1408
1409 #[test]
1410 fn reference_monitor_context_defaults_preserve_absent_contract_behavior() {
1411 let context = ToolPolicyContext::new("read", ToolActionKind::Read);
1412 assert_eq!(context.autonomy_mode, AutonomyMode::Safe);
1413 assert_eq!(context.workflow_type, WorkflowType::AdHoc);
1414 assert_eq!(context.risk_level, RiskLevel::Unknown);
1415 assert_eq!(context.workspace_scope, WorkspaceScope::CurrentDirectory);
1416 assert_eq!(context.trust_scope, TrustScopeContext::default());
1417 assert_eq!(
1418 context.trust_labels,
1419 Vec::<String>::new(),
1420 "labels are only populated when a workflow contract is explicitly threaded"
1421 );
1422 assert!(ReferenceMonitor
1423 .check_tool_action(&context, &RunPolicy::new())
1424 .is_allowed());
1425 }
1426
1427 #[test]
1428 fn reference_monitor_context_accepts_allow_all_placeholder_without_enforcing_it() {
1429 let mut contract = WorkflowContract::implicit("autonomous local work")
1430 .with_autonomy_mode(AutonomyMode::AllowAll);
1431 contract.id = Some("wf-allow-all".into());
1432 contract.workflow_type = WorkflowType::CodeChange;
1433 contract.risk_level = RiskLevel::High;
1434 contract.trust_scope.allow_external_context = false;
1435 contract.trust_scope.allow_durable_memory_writes = false;
1436
1437 let context = ToolPolicyContext::new("bash", ToolActionKind::Execute)
1438 .with_workflow_contract(&contract);
1439 assert_eq!(context.workflow_id.as_deref(), Some("wf-allow-all"));
1440 assert_eq!(context.autonomy_mode, AutonomyMode::AllowAll);
1441 assert_eq!(context.workflow_type, WorkflowType::CodeChange);
1442 assert_eq!(context.risk_level, RiskLevel::High);
1443 assert_eq!(context.workspace_scope, contract.workspace_scope);
1444 assert!(!context.trust_scope.allow_external_context);
1445 assert!(context
1446 .trust_labels
1447 .contains(&"external-context-blocked".to_string()));
1448 assert!(
1449 ReferenceMonitor
1450 .check_tool_action(&context, &RunPolicy::new())
1451 .is_allowed(),
1452 "allow-all is passed through as context only in 394.5.8"
1453 );
1454 }
1455
1456 #[test]
1457 fn policy_trace_record_includes_trust_scope_and_labels() {
1458 let mut contract = WorkflowContract::implicit("trusted review")
1459 .with_autonomy_mode(AutonomyMode::LocalAuto);
1460 contract.trust_scope.low_trust_requires_review = false;
1461 let context =
1462 ToolPolicyContext::new("read", ToolActionKind::Read).with_workflow_contract(&contract);
1463 let record = PolicyTraceRecord::from_context(&context, ToolPolicyDecision::allow());
1464 assert_eq!(record.autonomy_mode, AutonomyMode::LocalAuto);
1465 assert!(!record.trust_scope.low_trust_requires_review);
1466 assert!(record
1467 .trust_labels
1468 .contains(&"low-trust-review-not-required".to_string()));
1469
1470 let trace = record.to_trace_event("run_1");
1471 assert_eq!(trace.kind, "policy.checked");
1472 assert_eq!(
1473 trace.payload["trust_scope"]["low_trust_requires_review"],
1474 false
1475 );
1476 assert!(trace.payload["trust_labels"]
1477 .as_array()
1478 .unwrap()
1479 .iter()
1480 .any(|label| label == "low-trust-review-not-required"));
1481 }
1482
1483 #[test]
1484 fn non_allow_decisions_are_serializable_policy_records() {
1485 let monitor = ReferenceMonitor;
1486 let context = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1487
1488 let cases = [
1489 (
1490 monitor.ask_user_record(&context, "needs approval"),
1491 "ask_user",
1492 "ask_user_required",
1493 "unsupported_decision",
1494 ),
1495 (
1496 monitor.dry_run_only_record(&context, "dry run first"),
1497 "dry_run_only",
1498 "dry_run_required",
1499 "unsupported_decision",
1500 ),
1501 (
1502 monitor.sandbox_only_record(&context, "sandbox first"),
1503 "sandbox_only",
1504 "sandbox_required",
1505 "unsupported_decision",
1506 ),
1507 (
1508 monitor.require_verification_record(&context, "verify after"),
1509 "require_verification",
1510 "require_verification",
1511 "unsupported_decision",
1512 ),
1513 ];
1514
1515 for (record, decision_name, reason_code, detail_key) in cases {
1516 let json = serde_json::to_value(&record).unwrap();
1517 assert_eq!(json["decision"]["decision"], decision_name);
1518 assert_eq!(json["decision"]["reason"]["code"], reason_code);
1519 assert!(json["details"].get(detail_key).is_some());
1520 let trace = record.to_trace_event("run_1");
1521 assert_eq!(trace.kind, "policy.checked");
1522 assert_eq!(trace.payload["decision"]["decision"], decision_name);
1523 }
1524 }
1525
1526 #[test]
1527 fn dangerous_grant_records_fail_closed_above_allow_all() {
1528 let monitor = ReferenceMonitor;
1529 let context = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1530 let rails = [
1531 (
1532 DangerousRail::SecretExfiltration,
1533 "dangerous_secret_exfiltration",
1534 ),
1535 (DangerousRail::PrivateKeyRead, "dangerous_private_key_read"),
1536 (
1537 DangerousRail::OutsideWorkspaceDestructiveWrite,
1538 "dangerous_outside_workspace_destructive_write",
1539 ),
1540 (DangerousRail::ForcePush, "dangerous_force_push"),
1541 (
1542 DangerousRail::GlobalGitConfigMutation,
1543 "dangerous_global_git_config_mutation",
1544 ),
1545 (
1546 DangerousRail::ProductionDeploy,
1547 "dangerous_production_deploy",
1548 ),
1549 (
1550 DangerousRail::CloudResourceDeletion,
1551 "dangerous_cloud_resource_deletion",
1552 ),
1553 (
1554 DangerousRail::AuditLogDisable,
1555 "dangerous_audit_log_disable",
1556 ),
1557 ];
1558
1559 for (rail, code) in rails {
1560 let record = monitor.dangerous_grant_required_record(&context, rail);
1561 match record.decision {
1562 ToolPolicyDecision::Deny { ref reason } => {
1563 assert_eq!(reason.source, PolicySource::DangerousGrant);
1564 assert_eq!(reason.code, code);
1565 assert!(reason.message.contains("dangerous grant"));
1566 }
1567 other => panic!("dangerous rail must deny, got {other:?}"),
1568 }
1569 let json = serde_json::to_value(&record).unwrap();
1570 assert_eq!(
1571 json["details"]["dangerous_rail"],
1572 serde_json::to_value(rail).unwrap()
1573 );
1574 }
1575 }
1576
1577 #[test]
1578 fn autonomy_reference_monitor_maps_representative_tool_classes() {
1579 let monitor = ReferenceMonitor;
1580 let policy = RunPolicy::new();
1581
1582 let read = test_context(AutonomyMode::Suggest, "read", ToolActionKind::Read);
1583 assert!(monitor.check_tool_action(&read, &policy).is_allowed());
1584
1585 let write = test_context(AutonomyMode::Suggest, "write", ToolActionKind::Write);
1586 assert_reason_code(
1587 monitor.check_tool_action(&write, &policy),
1588 "autonomy_suggest_side_effect_denied",
1589 );
1590
1591 let local_write = test_context(AutonomyMode::LocalAuto, "write", ToolActionKind::Write);
1592 assert!(monitor
1593 .check_tool_action(&local_write, &policy)
1594 .is_allowed());
1595
1596 let mut local_network =
1597 test_context(AutonomyMode::LocalAuto, "web", ToolActionKind::Network);
1598 local_network.metadata.network = true;
1599 local_network.resource_scope = ResourceScope::Network {
1600 host: Some("example.com".into()),
1601 };
1602 assert_reason_code(
1603 monitor.check_tool_action(&local_network, &policy),
1604 "autonomy_network_requires_approval",
1605 );
1606
1607 let mut secret = test_context(AutonomyMode::AllowAll, "secret", ToolActionKind::Secret);
1608 secret.metadata.secrets = true;
1609 assert_reason_code(
1610 monitor.check_tool_action(&secret, &policy),
1611 "autonomy_secret_denied",
1612 );
1613
1614 let mut ci_bash = test_context(AutonomyMode::Ci, "bash", ToolActionKind::Execute);
1615 ci_bash.metadata.default_requires_approval = true;
1616 assert_reason_code(
1617 monitor.check_tool_action(&ci_bash, &policy),
1618 "autonomy_ci_approval_denied",
1619 );
1620 }
1621
1622 #[test]
1623 fn autonomy_reference_monitor_handles_outside_workspace_and_worktree_placeholder() {
1624 let monitor = ReferenceMonitor;
1625 let policy = RunPolicy::new();
1626
1627 let mut outside = test_context(AutonomyMode::AllowAllLocal, "write", ToolActionKind::Write);
1628 outside.cwd = Some(std::path::PathBuf::from("/repo"));
1629 outside.resource_scope = ResourceScope::File {
1630 path: std::path::PathBuf::from("/tmp/file"),
1631 };
1632 assert_reason_code(
1633 monitor.check_tool_action(&outside, &policy),
1634 "autonomy_outside_workspace_denied",
1635 );
1636
1637 let worktree = test_context(AutonomyMode::WorktreeAuto, "write", ToolActionKind::Write);
1638 let decision = monitor.check_tool_action(&worktree, &policy);
1639 assert_reason_code(decision.clone(), "autonomy_worktree_required");
1640 let message = policy_decision_reason(&decision).unwrap().message;
1641 assert!(message.contains("394.9"));
1642 assert!(message.contains("local-auto"));
1643
1644 let read_worktree = test_context(AutonomyMode::WorktreeAuto, "read", ToolActionKind::Read);
1645 assert_reason_code(
1646 monitor.check_tool_action(&read_worktree, &policy),
1647 "autonomy_worktree_required",
1648 );
1649
1650 let mut isolated = worktree.clone();
1651 isolated.workspace_scope = WorkspaceScope::Worktree {
1652 path: std::path::PathBuf::from("/repo-worktree"),
1653 branch: Some("workflow".into()),
1654 };
1655 assert!(monitor.check_tool_action(&isolated, &policy).is_allowed());
1656 }
1657
1658 #[test]
1659 fn autonomy_safe_preserves_existing_run_policy_precedence() {
1660 let monitor = ReferenceMonitor;
1661 let mut safe = test_context(AutonomyMode::Safe, "bash", ToolActionKind::Execute);
1662 safe.metadata.default_requires_approval = true;
1663 assert!(monitor
1664 .check_tool_action(&safe, &RunPolicy::new())
1665 .is_allowed());
1666
1667 assert_reason_code(
1668 monitor.check_tool_action(&safe, &RunPolicy::new().deny_tool("bash")),
1669 "run_policy_tool_denied",
1670 );
1671 }
1672
1673 fn test_context(mode: AutonomyMode, name: &str, kind: ToolActionKind) -> ToolPolicyContext {
1674 let mut context = ToolPolicyContext::new(name, kind);
1675 context.autonomy_mode = mode;
1676 context.metadata = ToolMetadata::for_tool_name(
1677 name,
1678 matches!(kind, ToolActionKind::Read | ToolActionKind::Search),
1679 );
1680 context.cwd = Some(std::path::PathBuf::from("/repo"));
1681 if context.metadata.workspace_write
1682 || matches!(kind, ToolActionKind::Write | ToolActionKind::Edit)
1683 {
1684 context.resource_scope = ResourceScope::File {
1685 path: std::path::PathBuf::from("/repo/src/lib.rs"),
1686 };
1687 }
1688 context
1689 }
1690
1691 fn policy_decision_reason(decision: &ToolPolicyDecision) -> Option<PolicyReason> {
1692 match decision {
1693 ToolPolicyDecision::Allow { .. } => None,
1694 ToolPolicyDecision::Deny { reason }
1695 | ToolPolicyDecision::AskUser { reason }
1696 | ToolPolicyDecision::DryRunOnly { reason }
1697 | ToolPolicyDecision::SandboxOnly { reason }
1698 | ToolPolicyDecision::RequireVerification { reason } => Some(reason.clone()),
1699 }
1700 }
1701
1702 fn assert_reason_code(decision: ToolPolicyDecision, code: &str) {
1703 match decision {
1704 ToolPolicyDecision::Deny { reason }
1705 | ToolPolicyDecision::AskUser { reason }
1706 | ToolPolicyDecision::DryRunOnly { reason }
1707 | ToolPolicyDecision::SandboxOnly { reason }
1708 | ToolPolicyDecision::RequireVerification { reason } => assert_eq!(reason.code, code),
1709 other => panic!("expected non-allow decision {code}, got {other:?}"),
1710 }
1711 }
1712
1713 #[test]
1714 fn reference_monitor_denies_low_trust_high_risk_escalation() {
1715 let monitor = ReferenceMonitor;
1716 let mut context = ToolPolicyContext::new("bash", ToolActionKind::Execute)
1717 .with_supporting_provenance(
1718 Provenance::external_web("https://example.com/instructions")
1719 .with_risk(crate::trust::RiskLabel::ContainsInstructions),
1720 );
1721 context.metadata = ToolMetadata::for_tool_name("bash", false);
1722
1723 let decision = monitor.check_tool_action(&context, &RunPolicy::new());
1724 match decision {
1725 ToolPolicyDecision::Deny { reason } => {
1726 assert_eq!(reason.source, PolicySource::TrustLabel);
1727 assert_eq!(reason.code, "low_trust_escalation_denied");
1728 assert!(reason.message.contains("https://example.com/instructions"));
1729 }
1730 other => panic!("expected trust denial, got {other:?}"),
1731 }
1732 }
1733
1734 #[test]
1735 fn reference_monitor_asks_user_for_low_trust_network_escalation() {
1736 let monitor = ReferenceMonitor;
1737 let mut context = ToolPolicyContext::new("web", ToolActionKind::Network)
1738 .with_supporting_provenance(Provenance::external_web("https://example.com"));
1739 context.metadata = ToolMetadata::for_tool_name("web", false);
1740 context.resource_scope = ResourceScope::Network {
1741 host: Some("api.example.com".into()),
1742 };
1743
1744 let decision = monitor.check_tool_action(&context, &RunPolicy::new());
1745 match decision {
1746 ToolPolicyDecision::AskUser { reason } => {
1747 assert_eq!(reason.source, PolicySource::TrustLabel);
1748 assert_eq!(reason.code, "low_trust_escalation_denied");
1749 }
1750 other => panic!("expected trust approval request, got {other:?}"),
1751 }
1752 }
1753
1754 #[test]
1755 fn reference_monitor_allows_trusted_support_and_low_risk_low_trust_context() {
1756 let monitor = ReferenceMonitor;
1757 let trusted = ToolPolicyContext::new("bash", ToolActionKind::Execute)
1758 .with_supporting_provenance(Provenance::user_instruction());
1759 assert!(monitor
1760 .check_tool_action(&trusted, &RunPolicy::new())
1761 .is_allowed());
1762
1763 let low_risk = ToolPolicyContext::new("read", ToolActionKind::Read)
1764 .with_supporting_provenance(Provenance::external_web("https://example.com"));
1765 assert!(monitor
1766 .check_tool_action(&low_risk, &RunPolicy::new())
1767 .is_allowed());
1768 }
1769
1770 #[test]
1771 fn reference_monitor_types_default_to_safe_unknown_context() {
1772 let context = ToolPolicyContext::new("mystery", ToolActionKind::Unknown);
1773 assert_eq!(context.tool_name, "mystery");
1774 assert_eq!(context.mode, AgentMode::Full);
1775 assert_eq!(context.autonomy_mode, AutonomyMode::default());
1776 assert_eq!(context.resource_scope, ResourceScope::None);
1777 assert_eq!(
1778 context.metadata,
1779 ToolMetadata::new("mystery", ToolActionKind::Unknown)
1780 );
1781 assert!(ToolPolicyDecision::default().is_allowed());
1782 }
1783
1784 #[test]
1785 fn reference_monitor_types_serialize_decision_variants() {
1786 let reason = PolicyReason::new(PolicySource::RunPolicy, "deny_tool", "tool denied");
1787 let decision = ToolPolicyDecision::Deny { reason };
1788 let json = serde_json::to_value(&decision).unwrap();
1789 assert_eq!(json["decision"], "deny");
1790 assert_eq!(json["reason"]["source"], "run_policy");
1791 assert_eq!(json["reason"]["code"], "deny_tool");
1792 }
1793
1794 #[test]
1795 fn reference_monitor_types_trace_record_carries_context() {
1796 let mut context = ToolPolicyContext::new("bash", ToolActionKind::Execute);
1797 context.run_id = Some("run_1".into());
1798 context.workflow_id = Some("394.5".into());
1799 context.turn = Some(2);
1800 context.tool_call_id = Some("call_1".into());
1801 context.resource_scope = ResourceScope::Command {
1802 program: "cargo".into(),
1803 };
1804
1805 let record = PolicyTraceRecord::from_context(&context, ToolPolicyDecision::allow());
1806 let json = serde_json::to_value(&record).unwrap();
1807 assert_eq!(json["run_id"], "run_1");
1808 assert_eq!(json["workflow_id"], "394.5");
1809 assert_eq!(json["tool_name"], "bash");
1810 assert_eq!(json["action_kind"], "execute");
1811 assert_eq!(json["decision"]["decision"], "allow");
1812 }
1813}