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