Skip to main content

imp_core/
reference_monitor.rs

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/// Central policy boundary for deciding whether a tool/action may proceed.
12///
13/// This initial type is a model-only facade. Later tasks route tool execution
14/// through it while preserving current behavior.
15#[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/// Context supplied to the reference monitor for a single tool/action decision.
551#[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/// Coarse kind of action a tool can perform.
703#[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/// Minimal tool manifest subset needed by the reference monitor.
722#[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/// Resource touched by a tool action.
908#[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/// Stable source/reason metadata for a policy decision.
997#[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/// Decision returned by the monitor for a tool/action.
1035#[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/// Serializable policy record suitable for trace/evidence pipelines.
1065#[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}