Skip to main content

harn_vm/orchestration/policy/
mod.rs

1//! Policy types and capability-ceiling enforcement.
2
3mod types;
4
5use std::cell::RefCell;
6use std::collections::BTreeMap;
7use std::rc::Rc;
8use std::thread_local;
9
10use serde::{Deserialize, Serialize};
11
12use super::glob_match;
13use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
14use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
15use crate::triggers::dispatcher::current_dispatch_context;
16use crate::trust_graph::AutonomyTier;
17use crate::value::{VmError, VmValue};
18use crate::workspace_path::{classify_workspace_path, WorkspacePathInfo};
19
20pub use crate::tool_annotations::{ToolArgSchema, ToolKind};
21pub use types::{
22    enforce_tool_arg_constraints, AutoCompactPolicy, BranchSemantics, CapabilityPolicy,
23    ContextPolicy, EqIgnored, EscalationPolicy, JoinPolicy, MapPolicy, ModelPolicy, ReducePolicy,
24    RetryPolicy, StageContract, ToolArgConstraint, TurnPolicy,
25};
26
27thread_local! {
28    static EXECUTION_POLICY_STACK: RefCell<Vec<CapabilityPolicy>> = const { RefCell::new(Vec::new()) };
29    static EXECUTION_APPROVAL_POLICY_STACK: RefCell<Vec<ToolApprovalPolicy>> = const { RefCell::new(Vec::new()) };
30    static TRUSTED_BRIDGE_CALL_DEPTH: RefCell<usize> = const { RefCell::new(0) };
31}
32
33pub fn push_execution_policy(policy: CapabilityPolicy) {
34    EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
35}
36
37pub fn pop_execution_policy() {
38    EXECUTION_POLICY_STACK.with(|stack| {
39        stack.borrow_mut().pop();
40    });
41}
42
43pub fn current_execution_policy() -> Option<CapabilityPolicy> {
44    EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
45}
46
47pub fn push_approval_policy(policy: ToolApprovalPolicy) {
48    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
49}
50
51pub fn pop_approval_policy() {
52    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| {
53        stack.borrow_mut().pop();
54    });
55}
56
57pub fn current_approval_policy() -> Option<ToolApprovalPolicy> {
58    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
59}
60
61pub fn current_tool_annotations(tool: &str) -> Option<ToolAnnotations> {
62    current_execution_policy().and_then(|policy| policy.tool_annotations.get(tool).cloned())
63}
64
65fn tool_kind_participates_in_write_allowlist(tool_name: &str) -> bool {
66    current_tool_annotations(tool_name)
67        .map(|annotations| !annotations.kind.is_read_only())
68        .unwrap_or(true)
69}
70
71pub struct TrustedBridgeCallGuard;
72
73pub fn allow_trusted_bridge_calls() -> TrustedBridgeCallGuard {
74    TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
75        *depth.borrow_mut() += 1;
76    });
77    TrustedBridgeCallGuard
78}
79
80impl Drop for TrustedBridgeCallGuard {
81    fn drop(&mut self) {
82        TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
83            let mut depth = depth.borrow_mut();
84            *depth = depth.saturating_sub(1);
85        });
86    }
87}
88
89fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
90    policy.tools.is_empty() || policy.tools.iter().any(|allowed| allowed == tool)
91}
92
93fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
94    policy.capabilities.is_empty()
95        || policy
96            .capabilities
97            .get(capability)
98            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
99}
100
101fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
102    fn rank(v: &str) -> usize {
103        match v {
104            "none" => 0,
105            "read_only" => 1,
106            "workspace_write" => 2,
107            "process_exec" => 3,
108            "network" => 4,
109            _ => 5,
110        }
111    }
112    policy
113        .side_effect_level
114        .as_ref()
115        .map(|allowed| rank(allowed) >= rank(requested))
116        .unwrap_or(true)
117}
118
119pub(super) fn reject_policy(reason: String) -> Result<(), VmError> {
120    Err(VmError::CategorizedError {
121        message: reason,
122        category: crate::value::ErrorCategory::ToolRejected,
123    })
124}
125
126/// Mutation classification for a tool, derived from the pipeline's
127/// declared `ToolKind`. Used in telemetry and pre/post-bridge payloads
128/// while those methods still exist. Returns `"other"` for unannotated
129/// tools (fail-safe; unknown tools don't auto-classify).
130pub fn current_tool_mutation_classification(tool_name: &str) -> String {
131    current_tool_annotations(tool_name)
132        .map(|annotations| annotations.kind.mutation_class().to_string())
133        .unwrap_or_else(|| "other".to_string())
134}
135
136/// Workspace paths declared by this tool call, read from the tool's
137/// annotated `arg_schema.path_params`. Unannotated tools declare no
138/// paths — the VM no longer guesses by common argument names.
139pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
140    current_tool_declared_path_entries(tool_name, args)
141        .into_iter()
142        .map(|entry| entry.display_path().to_string())
143        .collect()
144}
145
146/// Rich workspace-path descriptors declared by this tool call. Each
147/// entry preserves the original input while also projecting the path
148/// into workspace-relative and host-absolute forms when that mapping is
149/// known.
150pub fn current_tool_declared_path_entries(
151    tool_name: &str,
152    args: &serde_json::Value,
153) -> Vec<WorkspacePathInfo> {
154    let Some(map) = args.as_object() else {
155        return Vec::new();
156    };
157    let Some(annotations) = current_tool_annotations(tool_name) else {
158        return Vec::new();
159    };
160    let workspace_root = crate::stdlib::process::execution_root_path();
161    let mut entries = Vec::new();
162    for key in &annotations.arg_schema.path_params {
163        if let Some(value) = map.get(key) {
164            match value {
165                serde_json::Value::String(path) if !path.is_empty() => {
166                    entries.push(classify_workspace_path(path, Some(&workspace_root)));
167                }
168                serde_json::Value::Array(items) => {
169                    for item in items.iter().filter_map(|item| item.as_str()) {
170                        if !item.is_empty() {
171                            entries.push(classify_workspace_path(item, Some(&workspace_root)));
172                        }
173                    }
174                }
175                _ => {}
176            }
177        }
178    }
179    entries.sort_by(|a, b| a.display_path().cmp(b.display_path()));
180    entries.dedup_by(|left, right| left.policy_candidates() == right.policy_candidates());
181    entries
182}
183
184fn builtin_mutates_state(name: &str) -> bool {
185    matches!(
186        name,
187        "write_file"
188            | "append_file"
189            | "mkdir"
190            | "copy_file"
191            | "delete_file"
192            | "apply_edit"
193            | "exec"
194            | "exec_at"
195            | "shell"
196            | "shell_at"
197            | "host_call"
198            | "store_set"
199            | "store_delete"
200            | "store_save"
201            | "store_clear"
202            | "metadata_set"
203            | "metadata_save"
204            | "metadata_refresh_hashes"
205            | "invalidate_facts"
206            | "checkpoint"
207            | "checkpoint_delete"
208            | "checkpoint_clear"
209            | "__agent_state_write"
210            | "__agent_state_delete"
211            | "__agent_state_handoff"
212            | "mcp_release"
213    )
214}
215
216fn emit_autonomy_proposal_event(
217    tier: AutonomyTier,
218    builtin_name: &str,
219    args: &[VmValue],
220) -> Result<(), VmError> {
221    let Some(context) = current_dispatch_context() else {
222        return Ok(());
223    };
224    let Some(log) = active_event_log() else {
225        return Ok(());
226    };
227    let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
228        .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
229    let mut headers = BTreeMap::new();
230    headers.insert(
231        "trace_id".to_string(),
232        context.trigger_event.trace_id.0.clone(),
233    );
234    headers.insert("agent".to_string(), context.agent_id.clone());
235    headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
236    let payload = serde_json::json!({
237        "agent": context.agent_id,
238        "action": context.action,
239        "builtin": builtin_name,
240        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
241        "trace_id": context.trigger_event.trace_id.0,
242        "replay_of_event_id": context.replay_of_event_id,
243        "autonomy_tier": tier,
244        "proposal": true,
245    });
246    futures::executor::block_on(log.append(
247        &topic,
248        LogEvent::new("dispatch_proposed", payload).with_headers(headers),
249    ))
250    .map(|_| ())
251    .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
252}
253
254fn enforce_dispatch_autonomy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
255    let Some(context) = current_dispatch_context() else {
256        return Ok(());
257    };
258    if !builtin_mutates_state(name) {
259        return Ok(());
260    }
261    match context.autonomy_tier {
262        AutonomyTier::ActAuto => Ok(()),
263        AutonomyTier::ActWithApproval => reject_policy(format!(
264            "mutating builtin '{name}' is blocked for autonomy tier 'act_with_approval'; route the action through an approval-aware host/tool path"
265        )),
266        AutonomyTier::Shadow => {
267            emit_autonomy_proposal_event(AutonomyTier::Shadow, name, args)?;
268            reject_policy(format!(
269                "mutating builtin '{name}' is blocked for autonomy tier 'shadow'; proposal event emitted instead"
270            ))
271        }
272        AutonomyTier::Suggest => {
273            emit_autonomy_proposal_event(AutonomyTier::Suggest, name, args)?;
274            reject_policy(format!(
275                "mutating builtin '{name}' is blocked for autonomy tier 'suggest'; proposal event emitted and approval is required"
276            ))
277        }
278    }
279}
280
281pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
282    enforce_dispatch_autonomy_for_builtin(name, args)?;
283    let Some(policy) = current_execution_policy() else {
284        return Ok(());
285    };
286    match name {
287        "read_file" if !policy_allows_capability(&policy, "workspace", "read_text") => {
288            return reject_policy(format!(
289                "builtin '{name}' exceeds workspace.read_text ceiling"
290            ));
291        }
292        "list_dir" if !policy_allows_capability(&policy, "workspace", "list") => {
293            return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
294        }
295        "file_exists" | "stat" if !policy_allows_capability(&policy, "workspace", "exists") => {
296            return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
297        }
298        "write_file" | "append_file" | "mkdir" | "copy_file"
299            if !policy_allows_capability(&policy, "workspace", "write_text")
300                || !policy_allows_side_effect(&policy, "workspace_write") =>
301        {
302            return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
303        }
304        "delete_file"
305            if !policy_allows_capability(&policy, "workspace", "delete")
306                || !policy_allows_side_effect(&policy, "workspace_write") =>
307        {
308            return reject_policy(
309                "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
310            );
311        }
312        "apply_edit"
313            if !policy_allows_capability(&policy, "workspace", "apply_edit")
314                || !policy_allows_side_effect(&policy, "workspace_write") =>
315        {
316            return reject_policy(
317                "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
318            );
319        }
320        "exec" | "exec_at" | "shell" | "shell_at"
321            if !policy_allows_capability(&policy, "process", "exec")
322                || !policy_allows_side_effect(&policy, "process_exec") =>
323        {
324            return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
325        }
326        "http_get" | "http_post" | "http_put" | "http_patch" | "http_delete" | "http_request"
327            if !policy_allows_side_effect(&policy, "network") =>
328        {
329            return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
330        }
331        "mcp_connect"
332        | "mcp_call"
333        | "mcp_list_tools"
334        | "mcp_list_resources"
335        | "mcp_list_resource_templates"
336        | "mcp_read_resource"
337        | "mcp_list_prompts"
338        | "mcp_get_prompt"
339        | "mcp_server_info"
340        | "mcp_disconnect"
341            if !policy_allows_capability(&policy, "process", "exec")
342                || !policy_allows_side_effect(&policy, "process_exec") =>
343        {
344            return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
345        }
346        "host_call" => {
347            let name = args.first().map(|v| v.display()).unwrap_or_default();
348            let Some((capability, op)) = name.split_once('.') else {
349                return reject_policy(format!(
350                    "host_call '{name}' must use capability.operation naming"
351                ));
352            };
353            if !policy_allows_capability(&policy, capability, op) {
354                return reject_policy(format!(
355                    "host_call {capability}.{op} exceeds capability ceiling"
356                ));
357            }
358            let requested_side_effect = match (capability, op) {
359                ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
360                ("process", "exec") => "process_exec",
361                _ => "read_only",
362            };
363            if !policy_allows_side_effect(&policy, requested_side_effect) {
364                return reject_policy(format!(
365                    "host_call {capability}.{op} exceeds side-effect ceiling"
366                ));
367            }
368        }
369        _ => {}
370    }
371    Ok(())
372}
373
374pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
375    let trusted = TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow() > 0);
376    if trusted {
377        return Ok(());
378    }
379    if current_execution_policy().is_some() {
380        return reject_policy(format!(
381            "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
382        ));
383    }
384    Ok(())
385}
386
387pub fn enforce_current_policy_for_tool(tool_name: &str) -> Result<(), VmError> {
388    let Some(policy) = current_execution_policy() else {
389        return Ok(());
390    };
391    if !policy_allows_tool(&policy, tool_name) {
392        return reject_policy(format!("tool '{tool_name}' exceeds tool ceiling"));
393    }
394    if let Some(annotations) = policy.tool_annotations.get(tool_name) {
395        for (capability, ops) in &annotations.capabilities {
396            for op in ops {
397                if !policy_allows_capability(&policy, capability, op) {
398                    return reject_policy(format!(
399                        "tool '{tool_name}' exceeds capability ceiling: {capability}.{op}"
400                    ));
401                }
402            }
403        }
404        let requested_level = annotations.side_effect_level;
405        if requested_level != SideEffectLevel::None
406            && !policy_allows_side_effect(&policy, requested_level.as_str())
407        {
408            return reject_policy(format!(
409                "tool '{tool_name}' exceeds side-effect ceiling: {}",
410                requested_level.as_str()
411            ));
412        }
413    }
414    Ok(())
415}
416
417// ── Output visibility redaction ─────────────────────────────────────
418//
419// Transcript lifecycle (reset, fork, trim, compact) now lives on
420// `crate::agent_sessions` as explicit imperative builtins. All that
421// remains here is the per-call visibility filter, which is
422// output-shaping (not lifecycle).
423
424/// Filter a transcript dict down to the caller-visible subset, based
425/// on the `output_visibility` node option. `None` or any unknown
426/// visibility returns the transcript unchanged — callers are expected
427/// to validate the string against a known set upstream.
428pub fn redact_transcript_visibility(
429    transcript: &VmValue,
430    visibility: Option<&str>,
431) -> Option<VmValue> {
432    let Some(visibility) = visibility else {
433        return Some(transcript.clone());
434    };
435    if visibility != "public" && visibility != "public_only" {
436        return Some(transcript.clone());
437    }
438    let dict = transcript.as_dict()?;
439    let public_messages = match dict.get("messages") {
440        Some(VmValue::List(list)) => list
441            .iter()
442            .filter(|message| {
443                message
444                    .as_dict()
445                    .and_then(|d| d.get("role"))
446                    .map(|v| v.display())
447                    .map(|role| role != "tool_result")
448                    .unwrap_or(true)
449            })
450            .cloned()
451            .collect::<Vec<_>>(),
452        _ => Vec::new(),
453    };
454    let public_events = match dict.get("events") {
455        Some(VmValue::List(list)) => list
456            .iter()
457            .filter(|event| {
458                event
459                    .as_dict()
460                    .and_then(|d| d.get("visibility"))
461                    .map(|v| v.display())
462                    .map(|value| value == "public")
463                    .unwrap_or(true)
464            })
465            .cloned()
466            .collect::<Vec<_>>(),
467        _ => Vec::new(),
468    };
469    let mut redacted = dict.clone();
470    redacted.insert(
471        "messages".to_string(),
472        VmValue::List(Rc::new(public_messages)),
473    );
474    redacted.insert("events".to_string(), VmValue::List(Rc::new(public_events)));
475    Some(VmValue::Dict(Rc::new(redacted)))
476}
477
478pub fn builtin_ceiling() -> CapabilityPolicy {
479    CapabilityPolicy {
480        // `capabilities` is intentionally empty: the host capability manifest
481        // is the sole authority, and an allowlist here would silently block
482        // any capability the host adds later.
483        tools: Vec::new(),
484        capabilities: BTreeMap::new(),
485        workspace_roots: Vec::new(),
486        side_effect_level: Some("network".to_string()),
487        recursion_limit: Some(8),
488        tool_arg_constraints: Vec::new(),
489        tool_annotations: BTreeMap::new(),
490    }
491}
492
493/// Declarative policy for tool approval gating. Allows pipelines to
494/// specify which tools are auto-approved, auto-denied, or require
495/// host confirmation, plus write-path allowlists.
496#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
497#[serde(default)]
498pub struct ToolApprovalPolicy {
499    /// Glob patterns for tools that should be auto-approved.
500    #[serde(default)]
501    pub auto_approve: Vec<String>,
502    /// Glob patterns for tools that should always be denied.
503    #[serde(default)]
504    pub auto_deny: Vec<String>,
505    /// Glob patterns for tools that require host confirmation.
506    #[serde(default)]
507    pub require_approval: Vec<String>,
508    /// Glob patterns for writable paths.
509    #[serde(default)]
510    pub write_path_allowlist: Vec<String>,
511}
512
513/// Result of evaluating a tool call against a ToolApprovalPolicy.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum ToolApprovalDecision {
516    /// Tool is auto-approved by policy.
517    AutoApproved,
518    /// Tool is auto-denied by policy.
519    AutoDenied { reason: String },
520    /// Tool requires explicit host approval; the caller already owns the
521    /// tool name and args and forwards them to the host bridge.
522    RequiresHostApproval,
523}
524
525impl ToolApprovalPolicy {
526    /// Evaluate whether a tool call should be approved, denied, or needs
527    /// host confirmation.
528    pub fn evaluate(&self, tool_name: &str, args: &serde_json::Value) -> ToolApprovalDecision {
529        // Auto-deny takes precedence over every other pattern list.
530        for pattern in &self.auto_deny {
531            if glob_match(pattern, tool_name) {
532                return ToolApprovalDecision::AutoDenied {
533                    reason: format!("tool '{tool_name}' matches deny pattern '{pattern}'"),
534                };
535            }
536        }
537
538        if !self.write_path_allowlist.is_empty()
539            && tool_kind_participates_in_write_allowlist(tool_name)
540        {
541            let paths = super::current_tool_declared_path_entries(tool_name, args);
542            for path in &paths {
543                let allowed = self.write_path_allowlist.iter().any(|pattern| {
544                    path.policy_candidates()
545                        .iter()
546                        .any(|candidate| glob_match(pattern, candidate))
547                });
548                if !allowed {
549                    return ToolApprovalDecision::AutoDenied {
550                        reason: format!(
551                            "tool '{tool_name}' targets '{}' which is not in the write-path allowlist",
552                            path.display_path()
553                        ),
554                    };
555                }
556            }
557        }
558
559        for pattern in &self.auto_approve {
560            if glob_match(pattern, tool_name) {
561                return ToolApprovalDecision::AutoApproved;
562            }
563        }
564
565        for pattern in &self.require_approval {
566            if glob_match(pattern, tool_name) {
567                return ToolApprovalDecision::RequiresHostApproval;
568            }
569        }
570
571        ToolApprovalDecision::AutoApproved
572    }
573
574    /// Merge two approval policies, taking the most restrictive combination.
575    /// - auto_approve: only tools approved by BOTH policies stay approved
576    ///   (if either policy has no patterns, the other's patterns are used)
577    /// - auto_deny / require_approval: union (either policy can deny/gate)
578    /// - write_path_allowlist: intersection (both must allow the path)
579    pub fn intersect(&self, other: &ToolApprovalPolicy) -> ToolApprovalPolicy {
580        let auto_approve = if self.auto_approve.is_empty() {
581            other.auto_approve.clone()
582        } else if other.auto_approve.is_empty() {
583            self.auto_approve.clone()
584        } else {
585            self.auto_approve
586                .iter()
587                .filter(|p| other.auto_approve.contains(p))
588                .cloned()
589                .collect()
590        };
591        let mut auto_deny = self.auto_deny.clone();
592        auto_deny.extend(other.auto_deny.iter().cloned());
593        let mut require_approval = self.require_approval.clone();
594        require_approval.extend(other.require_approval.iter().cloned());
595        let write_path_allowlist = if self.write_path_allowlist.is_empty() {
596            other.write_path_allowlist.clone()
597        } else if other.write_path_allowlist.is_empty() {
598            self.write_path_allowlist.clone()
599        } else {
600            self.write_path_allowlist
601                .iter()
602                .filter(|p| other.write_path_allowlist.contains(p))
603                .cloned()
604                .collect()
605        };
606        ToolApprovalPolicy {
607            auto_approve,
608            auto_deny,
609            require_approval,
610            write_path_allowlist,
611        }
612    }
613}
614
615#[cfg(test)]
616mod approval_policy_tests {
617    use super::*;
618    use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
619    use crate::tool_annotations::{ToolAnnotations, ToolArgSchema, ToolKind};
620
621    #[test]
622    fn auto_deny_takes_precedence_over_auto_approve() {
623        let policy = ToolApprovalPolicy {
624            auto_approve: vec!["*".to_string()],
625            auto_deny: vec!["dangerous_*".to_string()],
626            ..Default::default()
627        };
628        assert_eq!(
629            policy.evaluate("dangerous_rm", &serde_json::json!({})),
630            ToolApprovalDecision::AutoDenied {
631                reason: "tool 'dangerous_rm' matches deny pattern 'dangerous_*'".to_string()
632            }
633        );
634    }
635
636    #[test]
637    fn auto_approve_matches_glob() {
638        let policy = ToolApprovalPolicy {
639            auto_approve: vec!["read*".to_string(), "search*".to_string()],
640            ..Default::default()
641        };
642        assert_eq!(
643            policy.evaluate("read_file", &serde_json::json!({})),
644            ToolApprovalDecision::AutoApproved
645        );
646        assert_eq!(
647            policy.evaluate("search", &serde_json::json!({})),
648            ToolApprovalDecision::AutoApproved
649        );
650    }
651
652    #[test]
653    fn require_approval_emits_decision() {
654        let policy = ToolApprovalPolicy {
655            require_approval: vec!["edit*".to_string()],
656            ..Default::default()
657        };
658        let decision = policy.evaluate("edit_file", &serde_json::json!({"path": "foo.rs"}));
659        assert!(matches!(
660            decision,
661            ToolApprovalDecision::RequiresHostApproval
662        ));
663    }
664
665    #[test]
666    fn unmatched_tool_defaults_to_approved() {
667        let policy = ToolApprovalPolicy {
668            auto_approve: vec!["read*".to_string()],
669            require_approval: vec!["edit*".to_string()],
670            ..Default::default()
671        };
672        assert_eq!(
673            policy.evaluate("unknown_tool", &serde_json::json!({})),
674            ToolApprovalDecision::AutoApproved
675        );
676    }
677
678    #[test]
679    fn intersect_merges_deny_lists() {
680        let a = ToolApprovalPolicy {
681            auto_deny: vec!["rm*".to_string()],
682            ..Default::default()
683        };
684        let b = ToolApprovalPolicy {
685            auto_deny: vec!["drop*".to_string()],
686            ..Default::default()
687        };
688        let merged = a.intersect(&b);
689        assert_eq!(merged.auto_deny.len(), 2);
690    }
691
692    #[test]
693    fn intersect_restricts_auto_approve_to_common_patterns() {
694        let a = ToolApprovalPolicy {
695            auto_approve: vec!["read*".to_string(), "search*".to_string()],
696            ..Default::default()
697        };
698        let b = ToolApprovalPolicy {
699            auto_approve: vec!["read*".to_string(), "write*".to_string()],
700            ..Default::default()
701        };
702        let merged = a.intersect(&b);
703        assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
704    }
705
706    #[test]
707    fn intersect_defers_auto_approve_when_one_side_empty() {
708        let a = ToolApprovalPolicy {
709            auto_approve: vec!["read*".to_string()],
710            ..Default::default()
711        };
712        let b = ToolApprovalPolicy::default();
713        let merged = a.intersect(&b);
714        assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
715    }
716
717    #[test]
718    fn write_path_allowlist_matches_recovered_workspace_relative_path() {
719        let temp = tempfile::tempdir().unwrap();
720        std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
721        std::fs::write(temp.path().join("packages/demo/file.txt"), "ok").unwrap();
722        crate::stdlib::process::set_thread_execution_context(Some(
723            crate::orchestration::RunExecutionRecord {
724                cwd: Some(temp.path().to_string_lossy().into_owned()),
725                source_dir: Some(temp.path().to_string_lossy().into_owned()),
726                env: BTreeMap::new(),
727                adapter: None,
728                repo_path: None,
729                worktree_path: None,
730                branch: None,
731                base_ref: None,
732                cleanup: None,
733            },
734        ));
735
736        let mut tool_annotations = BTreeMap::new();
737        tool_annotations.insert(
738            "write_file".to_string(),
739            ToolAnnotations {
740                kind: ToolKind::Edit,
741                arg_schema: ToolArgSchema {
742                    path_params: vec!["path".to_string()],
743                    ..Default::default()
744                },
745                ..Default::default()
746            },
747        );
748        push_execution_policy(CapabilityPolicy {
749            tool_annotations,
750            ..Default::default()
751        });
752
753        let policy = ToolApprovalPolicy {
754            write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
755            ..Default::default()
756        };
757        let decision = policy.evaluate(
758            "write_file",
759            &serde_json::json!({"path": "/packages/demo/file.txt"}),
760        );
761        assert_eq!(decision, ToolApprovalDecision::AutoApproved);
762
763        pop_execution_policy();
764        crate::stdlib::process::set_thread_execution_context(None);
765    }
766
767    #[test]
768    fn write_path_allowlist_does_not_block_read_only_tools() {
769        let temp = tempfile::tempdir().unwrap();
770        std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
771        std::fs::write(temp.path().join("packages/demo/context.txt"), "ok").unwrap();
772        crate::stdlib::process::set_thread_execution_context(Some(
773            crate::orchestration::RunExecutionRecord {
774                cwd: Some(temp.path().to_string_lossy().into_owned()),
775                source_dir: Some(temp.path().to_string_lossy().into_owned()),
776                env: BTreeMap::new(),
777                adapter: None,
778                repo_path: None,
779                worktree_path: None,
780                branch: None,
781                base_ref: None,
782                cleanup: None,
783            },
784        ));
785
786        let mut tool_annotations = BTreeMap::new();
787        tool_annotations.insert(
788            "read_file".to_string(),
789            ToolAnnotations {
790                kind: ToolKind::Read,
791                arg_schema: ToolArgSchema {
792                    path_params: vec!["path".to_string()],
793                    ..Default::default()
794                },
795                ..Default::default()
796            },
797        );
798        push_execution_policy(CapabilityPolicy {
799            tool_annotations,
800            ..Default::default()
801        });
802
803        let policy = ToolApprovalPolicy {
804            write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
805            ..Default::default()
806        };
807        let decision = policy.evaluate(
808            "read_file",
809            &serde_json::json!({"path": "/packages/demo/context.txt"}),
810        );
811        assert_eq!(decision, ToolApprovalDecision::AutoApproved);
812
813        pop_execution_policy();
814        crate::stdlib::process::set_thread_execution_context(None);
815    }
816}
817
818#[cfg(test)]
819mod turn_policy_tests {
820    use super::TurnPolicy;
821
822    #[test]
823    fn default_allows_done_sentinel() {
824        let policy = TurnPolicy::default();
825        assert!(policy.allow_done_sentinel);
826        assert!(!policy.require_action_or_yield);
827        assert!(policy.max_prose_chars.is_none());
828    }
829
830    #[test]
831    fn deserializing_partial_dict_preserves_done_sentinel_pathway() {
832        // Pre-existing workflows passed `turn_policy: { require_action_or_yield: true }`
833        // without knowing about `allow_done_sentinel`. Deserializing such a dict
834        // must keep the done-sentinel pathway enabled so persistent agent loops
835        // don't lose their completion signal in this release.
836        let policy: TurnPolicy =
837            serde_json::from_value(serde_json::json!({ "require_action_or_yield": true }))
838                .expect("deserialize");
839        assert!(policy.require_action_or_yield);
840        assert!(policy.allow_done_sentinel);
841    }
842
843    #[test]
844    fn deserializing_explicit_false_disables_done_sentinel() {
845        let policy: TurnPolicy = serde_json::from_value(serde_json::json!({
846            "require_action_or_yield": true,
847            "allow_done_sentinel": false,
848        }))
849        .expect("deserialize");
850        assert!(policy.require_action_or_yield);
851        assert!(!policy.allow_done_sentinel);
852    }
853}
854
855#[cfg(test)]
856mod visibility_redaction_tests {
857    use super::*;
858    use crate::value::VmValue;
859
860    fn mock_transcript() -> VmValue {
861        let messages = vec![
862            serde_json::json!({"role": "user", "content": "hi"}),
863            serde_json::json!({"role": "assistant", "content": "hello"}),
864            serde_json::json!({"role": "tool_result", "content": "internal tool output"}),
865        ];
866        crate::llm::helpers::transcript_to_vm_with_events(
867            Some("test-id".to_string()),
868            None,
869            None,
870            &messages,
871            Vec::new(),
872            Vec::new(),
873            Some("active"),
874        )
875    }
876
877    fn message_count(transcript: &VmValue) -> usize {
878        transcript
879            .as_dict()
880            .and_then(|d| d.get("messages"))
881            .and_then(|v| match v {
882                VmValue::List(list) => Some(list.len()),
883                _ => None,
884            })
885            .unwrap_or(0)
886    }
887
888    #[test]
889    fn visibility_none_returns_unchanged() {
890        let t = mock_transcript();
891        let result = redact_transcript_visibility(&t, None).unwrap();
892        assert_eq!(message_count(&result), 3);
893    }
894
895    #[test]
896    fn visibility_public_drops_tool_results() {
897        let t = mock_transcript();
898        let result = redact_transcript_visibility(&t, Some("public")).unwrap();
899        assert_eq!(message_count(&result), 2);
900    }
901
902    #[test]
903    fn visibility_unknown_string_is_pass_through() {
904        let t = mock_transcript();
905        let result = redact_transcript_visibility(&t, Some("internal")).unwrap();
906        assert_eq!(message_count(&result), 3);
907    }
908}