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