Skip to main content

harn_vm/orchestration/
policy.rs

1//! Policy types and capability-ceiling enforcement.
2
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::rc::Rc;
6use std::thread_local;
7
8use serde::{Deserialize, Serialize};
9
10use super::{glob_match, new_id};
11use crate::value::{VmError, VmValue};
12
13thread_local! {
14    static EXECUTION_POLICY_STACK: RefCell<Vec<CapabilityPolicy>> = const { RefCell::new(Vec::new()) };
15}
16
17// ── Per-agent policy with argument patterns ───────────────────────────
18
19/// Extended policy that supports argument-level constraints.
20#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(default)]
22pub struct ToolArgConstraint {
23    /// Tool name to constrain.
24    pub tool: String,
25    /// Glob patterns that the first string argument must match.
26    /// If empty, no argument constraint is applied.
27    pub arg_patterns: Vec<String>,
28}
29
30/// Check if a tool call satisfies argument constraints in the policy.
31pub fn enforce_tool_arg_constraints(
32    policy: &CapabilityPolicy,
33    tool_name: &str,
34    args: &serde_json::Value,
35) -> Result<(), VmError> {
36    for constraint in &policy.tool_arg_constraints {
37        if !glob_match(&constraint.tool, tool_name) {
38            continue;
39        }
40        if constraint.arg_patterns.is_empty() {
41            continue;
42        }
43        let first_arg = args
44            .as_object()
45            .and_then(|o| {
46                policy
47                    .tool_metadata
48                    .get(tool_name)
49                    .into_iter()
50                    .flat_map(|metadata| metadata.path_params.iter())
51                    .find_map(|param| o.get(param).and_then(|v| v.as_str()))
52                    .or_else(|| o.values().find_map(|v| v.as_str()))
53            })
54            .or_else(|| args.as_str())
55            .unwrap_or("");
56        let matches = constraint
57            .arg_patterns
58            .iter()
59            .any(|pattern| glob_match(pattern, first_arg));
60        if !matches {
61            return reject_policy(format!(
62                "tool '{tool_name}' argument '{first_arg}' does not match allowed patterns: {:?}",
63                constraint.arg_patterns
64            ));
65        }
66    }
67    Ok(())
68}
69
70#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(default)]
72pub struct ToolRuntimePolicyMetadata {
73    pub capabilities: BTreeMap<String, Vec<String>>,
74    pub side_effect_level: Option<String>,
75    pub path_params: Vec<String>,
76    pub mutation_classification: Option<String>,
77}
78
79#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(default)]
81pub struct CapabilityPolicy {
82    pub tools: Vec<String>,
83    pub capabilities: BTreeMap<String, Vec<String>>,
84    pub workspace_roots: Vec<String>,
85    pub side_effect_level: Option<String>,
86    pub recursion_limit: Option<usize>,
87    /// Argument-level constraints for specific tools.
88    #[serde(default)]
89    pub tool_arg_constraints: Vec<ToolArgConstraint>,
90    #[serde(default)]
91    pub tool_metadata: BTreeMap<String, ToolRuntimePolicyMetadata>,
92}
93
94impl CapabilityPolicy {
95    pub fn intersect(&self, requested: &CapabilityPolicy) -> Result<CapabilityPolicy, String> {
96        let side_effect_level = match (&self.side_effect_level, &requested.side_effect_level) {
97            (Some(a), Some(b)) => Some(min_side_effect(a, b).to_string()),
98            (Some(a), None) => Some(a.clone()),
99            (None, Some(b)) => Some(b.clone()),
100            (None, None) => None,
101        };
102
103        if !self.tools.is_empty() {
104            let denied: Vec<String> = requested
105                .tools
106                .iter()
107                .filter(|tool| !self.tools.contains(*tool))
108                .cloned()
109                .collect();
110            if !denied.is_empty() {
111                return Err(format!(
112                    "requested tools exceed host ceiling: {}",
113                    denied.join(", ")
114                ));
115            }
116        }
117
118        for (capability, requested_ops) in &requested.capabilities {
119            if let Some(allowed_ops) = self.capabilities.get(capability) {
120                let denied: Vec<String> = requested_ops
121                    .iter()
122                    .filter(|op| !allowed_ops.contains(*op))
123                    .cloned()
124                    .collect();
125                if !denied.is_empty() {
126                    return Err(format!(
127                        "requested capability operations exceed host ceiling: {}.{}",
128                        capability,
129                        denied.join(",")
130                    ));
131                }
132            } else if !self.capabilities.is_empty() {
133                return Err(format!(
134                    "requested capability exceeds host ceiling: {capability}"
135                ));
136            }
137        }
138
139        let tools = if self.tools.is_empty() {
140            requested.tools.clone()
141        } else if requested.tools.is_empty() {
142            self.tools.clone()
143        } else {
144            requested
145                .tools
146                .iter()
147                .filter(|tool| self.tools.contains(*tool))
148                .cloned()
149                .collect()
150        };
151
152        let capabilities = if self.capabilities.is_empty() {
153            requested.capabilities.clone()
154        } else if requested.capabilities.is_empty() {
155            self.capabilities.clone()
156        } else {
157            requested
158                .capabilities
159                .iter()
160                .filter_map(|(capability, requested_ops)| {
161                    self.capabilities.get(capability).map(|allowed_ops| {
162                        (
163                            capability.clone(),
164                            requested_ops
165                                .iter()
166                                .filter(|op| allowed_ops.contains(*op))
167                                .cloned()
168                                .collect::<Vec<_>>(),
169                        )
170                    })
171                })
172                .collect()
173        };
174
175        let workspace_roots = if self.workspace_roots.is_empty() {
176            requested.workspace_roots.clone()
177        } else if requested.workspace_roots.is_empty() {
178            self.workspace_roots.clone()
179        } else {
180            requested
181                .workspace_roots
182                .iter()
183                .filter(|root| self.workspace_roots.contains(*root))
184                .cloned()
185                .collect()
186        };
187
188        let recursion_limit = match (self.recursion_limit, requested.recursion_limit) {
189            (Some(a), Some(b)) => Some(a.min(b)),
190            (Some(a), None) => Some(a),
191            (None, Some(b)) => Some(b),
192            (None, None) => None,
193        };
194
195        // Merge arg constraints from both sides
196        let mut tool_arg_constraints = self.tool_arg_constraints.clone();
197        tool_arg_constraints.extend(requested.tool_arg_constraints.clone());
198
199        let tool_metadata = tools
200            .iter()
201            .filter_map(|tool| {
202                requested
203                    .tool_metadata
204                    .get(tool)
205                    .or_else(|| self.tool_metadata.get(tool))
206                    .cloned()
207                    .map(|metadata| (tool.clone(), metadata))
208            })
209            .collect();
210
211        Ok(CapabilityPolicy {
212            tools,
213            capabilities,
214            workspace_roots,
215            side_effect_level,
216            recursion_limit,
217            tool_arg_constraints,
218            tool_metadata,
219        })
220    }
221}
222
223fn min_side_effect<'a>(a: &'a str, b: &'a str) -> &'a str {
224    fn rank(v: &str) -> usize {
225        match v {
226            "none" => 0,
227            "read_only" => 1,
228            "workspace_write" => 2,
229            "process_exec" => 3,
230            "network" => 4,
231            _ => 5,
232        }
233    }
234    if rank(a) <= rank(b) {
235        a
236    } else {
237        b
238    }
239}
240
241#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
242#[serde(default)]
243pub struct TurnPolicy {
244    /// When true, text-only responses in a tool-capable stage are treated as
245    /// invalid unless they switch phase / finish the stage. This keeps action
246    /// stages moving instead of drifting into narration.
247    pub require_action_or_yield: bool,
248    /// When false, workflow-owned action stages should hand control back via
249    /// successful tool calls instead of advertising an additional done
250    /// sentinel pathway in corrective nudges.
251    #[serde(default = "default_true")]
252    pub allow_done_sentinel: bool,
253    /// Optional visible prose budget for a single assistant turn. When the
254    /// assistant exceeds it, the recorded transcript keeps only a shortened
255    /// version and the next corrective nudge reminds the model to stay brief.
256    pub max_prose_chars: Option<usize>,
257}
258
259impl Default for TurnPolicy {
260    fn default() -> Self {
261        Self {
262            require_action_or_yield: false,
263            allow_done_sentinel: true,
264            max_prose_chars: None,
265        }
266    }
267}
268
269fn default_true() -> bool {
270    true
271}
272
273#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
274#[serde(default)]
275pub struct ModelPolicy {
276    pub provider: Option<String>,
277    pub model: Option<String>,
278    pub model_tier: Option<String>,
279    pub temperature: Option<f64>,
280    pub max_tokens: Option<i64>,
281    /// Maximum agent_loop iterations for this stage. Overrides the default 16.
282    pub max_iterations: Option<usize>,
283    /// Maximum consecutive text-only (no tool call) responses before declaring stuck.
284    pub max_nudges: Option<usize>,
285    /// Custom nudge message injected when the model produces text without tool calls.
286    /// If omitted, the VM uses a generic "Continue — use a tool call" message.
287    pub nudge: Option<String>,
288    /// Few-shot tool-call examples injected into the tool contract prompt,
289    /// shown before the tool schema listing. Pipelines provide these —
290    /// the VM has no hardcoded tool names.
291    pub tool_examples: Option<String>,
292    /// Optional Harn closure called after each tool-calling turn.
293    /// Receives turn metadata; returns either a string user message to inject,
294    /// a bool stop flag, or a dict like {message, stop}.
295    /// Wrapped in EqIgnored so it doesn't affect PartialEq derivation.
296    #[serde(skip)]
297    pub post_turn_callback: Option<EqIgnored<VmValue>>,
298    /// When set, the stage stops after any tool-calling turn whose successful
299    /// results include one of these tool names. This is useful for
300    /// workflow-owned verify loops where a productive write turn should hand
301    /// control back to verification immediately.
302    pub stop_after_successful_tools: Option<Vec<String>>,
303    /// When set, the stage is reported as failed unless at least one of these
304    /// tool names succeeds during the interaction. Pipelines use this to
305    /// assert a stage cannot quietly finish without running a specific tool.
306    pub require_successful_tools: Option<Vec<String>>,
307    /// Turn-shape constraints for action stages.
308    pub turn_policy: Option<TurnPolicy>,
309}
310
311/// Wrapper that always compares equal, allowing non-Eq types in derived PartialEq structs.
312#[derive(Clone, Debug, Default)]
313pub struct EqIgnored<T>(pub T);
314
315impl<T> PartialEq for EqIgnored<T> {
316    fn eq(&self, _: &Self) -> bool {
317        true
318    }
319}
320
321impl<T> std::ops::Deref for EqIgnored<T> {
322    type Target = T;
323    fn deref(&self) -> &T {
324        &self.0
325    }
326}
327
328#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
329#[serde(default)]
330pub struct TranscriptPolicy {
331    pub mode: Option<String>,
332    pub visibility: Option<String>,
333    pub summarize: bool,
334    pub compact: bool,
335    pub keep_last: Option<usize>,
336    /// Enable per-turn auto-compaction within agent loops.
337    pub auto_compact: bool,
338    /// Token threshold for tier-1 compaction.
339    pub compact_threshold: Option<usize>,
340    /// Max chars per tool result before compression.
341    pub tool_output_max_chars: Option<usize>,
342    /// Tier-1 compaction strategy name (e.g., "observation_mask", "llm").
343    pub compact_strategy: Option<String>,
344    /// Token threshold for tier-2 aggressive compaction.
345    pub hard_limit_tokens: Option<usize>,
346    /// Tier-2 compaction strategy name.
347    pub hard_limit_strategy: Option<String>,
348}
349
350#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
351#[serde(default)]
352pub struct ContextPolicy {
353    pub max_artifacts: Option<usize>,
354    pub max_tokens: Option<usize>,
355    pub reserve_tokens: Option<usize>,
356    pub include_kinds: Vec<String>,
357    pub exclude_kinds: Vec<String>,
358    pub prioritize_kinds: Vec<String>,
359    pub pinned_ids: Vec<String>,
360    pub include_stages: Vec<String>,
361    pub prefer_recent: bool,
362    pub prefer_fresh: bool,
363    pub render: Option<String>,
364}
365
366#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
367#[serde(default)]
368pub struct RetryPolicy {
369    pub max_attempts: usize,
370    pub verify: bool,
371    pub repair: bool,
372    /// Initial backoff duration in milliseconds between retry attempts.
373    /// When `None`, retries proceed without delay.
374    #[serde(default)]
375    pub backoff_ms: Option<u64>,
376    /// Multiplier applied to `backoff_ms` after each retry attempt.
377    /// Defaults to 2.0 when `backoff_ms` is set and this field is `None`.
378    #[serde(default)]
379    pub backoff_multiplier: Option<f64>,
380}
381
382#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
383#[serde(default)]
384pub struct StageContract {
385    pub input_kinds: Vec<String>,
386    pub output_kinds: Vec<String>,
387    pub min_inputs: Option<usize>,
388    pub max_inputs: Option<usize>,
389    pub require_transcript: bool,
390    pub schema: Option<serde_json::Value>,
391}
392
393#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
394#[serde(default)]
395pub struct BranchSemantics {
396    pub success: Option<String>,
397    pub failure: Option<String>,
398    pub verify_pass: Option<String>,
399    pub verify_fail: Option<String>,
400    pub condition_true: Option<String>,
401    pub condition_false: Option<String>,
402    pub loop_continue: Option<String>,
403    pub loop_exit: Option<String>,
404    pub escalation: Option<String>,
405}
406
407#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
408#[serde(default)]
409pub struct MapPolicy {
410    pub items: Vec<serde_json::Value>,
411    pub item_artifact_kind: Option<String>,
412    pub output_kind: Option<String>,
413    pub max_items: Option<usize>,
414    pub max_concurrent: Option<usize>,
415}
416
417#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
418#[serde(default)]
419pub struct JoinPolicy {
420    pub strategy: String,
421    pub require_all_inputs: bool,
422    pub min_completed: Option<usize>,
423}
424
425#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
426#[serde(default)]
427pub struct ReducePolicy {
428    pub strategy: String,
429    pub separator: Option<String>,
430    pub output_kind: Option<String>,
431}
432
433#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
434#[serde(default)]
435pub struct EscalationPolicy {
436    pub level: Option<String>,
437    pub queue: Option<String>,
438    pub reason: Option<String>,
439}
440
441// ── Execution policy stack ──────────────────────────────────────────
442
443pub fn push_execution_policy(policy: CapabilityPolicy) {
444    EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
445}
446
447pub fn pop_execution_policy() {
448    EXECUTION_POLICY_STACK.with(|stack| {
449        stack.borrow_mut().pop();
450    });
451}
452
453pub fn current_execution_policy() -> Option<CapabilityPolicy> {
454    EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
455}
456
457pub fn current_tool_metadata(tool: &str) -> Option<ToolRuntimePolicyMetadata> {
458    current_execution_policy().and_then(|policy| policy.tool_metadata.get(tool).cloned())
459}
460
461fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
462    policy.tools.is_empty() || policy.tools.iter().any(|allowed| allowed == tool)
463}
464
465fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
466    policy.capabilities.is_empty()
467        || policy
468            .capabilities
469            .get(capability)
470            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
471}
472
473fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
474    fn rank(v: &str) -> usize {
475        match v {
476            "none" => 0,
477            "read_only" => 1,
478            "workspace_write" => 2,
479            "process_exec" => 3,
480            "network" => 4,
481            _ => 5,
482        }
483    }
484    policy
485        .side_effect_level
486        .as_ref()
487        .map(|allowed| rank(allowed) >= rank(requested))
488        .unwrap_or(true)
489}
490
491fn reject_policy(reason: String) -> Result<(), VmError> {
492    Err(VmError::CategorizedError {
493        message: reason,
494        category: crate::value::ErrorCategory::ToolRejected,
495    })
496}
497
498fn fallback_mutation_classification(tool_name: &str) -> String {
499    let lower = tool_name.to_ascii_lowercase();
500    if lower.starts_with("mcp_") {
501        return "host_defined".to_string();
502    }
503    if lower == "exec"
504        || lower == "shell"
505        || lower == "exec_at"
506        || lower == "shell_at"
507        || lower == "run"
508        || lower.starts_with("run_")
509    {
510        return "ambient_side_effect".to_string();
511    }
512    if lower.starts_with("delete")
513        || lower.starts_with("remove")
514        || lower.starts_with("move")
515        || lower.starts_with("rename")
516    {
517        return "destructive".to_string();
518    }
519    if lower.contains("write")
520        || lower.contains("edit")
521        || lower.contains("patch")
522        || lower.contains("create")
523        || lower.contains("scaffold")
524        || lower.starts_with("insert")
525        || lower.starts_with("replace")
526        || lower == "add_import"
527    {
528        return "apply_workspace".to_string();
529    }
530    "read_only".to_string()
531}
532
533pub fn current_tool_mutation_classification(tool_name: &str) -> String {
534    current_tool_metadata(tool_name)
535        .and_then(|metadata| metadata.mutation_classification)
536        .unwrap_or_else(|| fallback_mutation_classification(tool_name))
537}
538
539pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
540    let Some(map) = args.as_object() else {
541        return Vec::new();
542    };
543    let path_keys = current_tool_metadata(tool_name)
544        .map(|metadata| metadata.path_params)
545        .filter(|keys| !keys.is_empty())
546        .unwrap_or_else(|| {
547            vec![
548                "path".to_string(),
549                "file".to_string(),
550                "cwd".to_string(),
551                "repo".to_string(),
552                "target".to_string(),
553                "destination".to_string(),
554            ]
555        });
556    let mut paths = Vec::new();
557    for key in path_keys {
558        if let Some(value) = map.get(&key).and_then(|value| value.as_str()) {
559            if !value.is_empty() {
560                paths.push(value.to_string());
561            }
562        }
563    }
564    if let Some(items) = map.get("paths").and_then(|value| value.as_array()) {
565        for item in items {
566            if let Some(value) = item.as_str() {
567                if !value.is_empty() {
568                    paths.push(value.to_string());
569                }
570            }
571        }
572    }
573    paths.sort();
574    paths.dedup();
575    paths
576}
577
578pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
579    let Some(policy) = current_execution_policy() else {
580        return Ok(());
581    };
582    match name {
583        "read" | "read_file" => {
584            if !policy_allows_tool(&policy, name)
585                || !policy_allows_capability(&policy, "workspace", "read_text")
586            {
587                return reject_policy(format!(
588                    "builtin '{name}' exceeds workspace.read_text ceiling"
589                ));
590            }
591        }
592        "search" | "list_dir" => {
593            if !policy_allows_tool(&policy, name)
594                || !policy_allows_capability(&policy, "workspace", "list")
595            {
596                return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
597            }
598        }
599        "file_exists" | "stat" => {
600            if !policy_allows_capability(&policy, "workspace", "exists") {
601                return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
602            }
603        }
604        "edit" | "write_file" | "append_file" | "mkdir" | "copy_file" => {
605            if !policy_allows_tool(&policy, "edit")
606                || !policy_allows_capability(&policy, "workspace", "write_text")
607                || !policy_allows_side_effect(&policy, "workspace_write")
608            {
609                return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
610            }
611        }
612        "delete_file" => {
613            if !policy_allows_capability(&policy, "workspace", "delete")
614                || !policy_allows_side_effect(&policy, "workspace_write")
615            {
616                return reject_policy(
617                    "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
618                );
619            }
620        }
621        "apply_edit" => {
622            if !policy_allows_capability(&policy, "workspace", "apply_edit")
623                || !policy_allows_side_effect(&policy, "workspace_write")
624            {
625                return reject_policy(
626                    "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
627                );
628            }
629        }
630        "exec" | "exec_at" | "shell" | "shell_at" | "run_command" => {
631            if !policy_allows_tool(&policy, "run")
632                || !policy_allows_capability(&policy, "process", "exec")
633                || !policy_allows_side_effect(&policy, "process_exec")
634            {
635                return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
636            }
637        }
638        "http_get" | "http_post" | "http_put" | "http_patch" | "http_delete" | "http_request" => {
639            if !policy_allows_side_effect(&policy, "network") {
640                return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
641            }
642        }
643        "mcp_connect"
644        | "mcp_call"
645        | "mcp_list_tools"
646        | "mcp_list_resources"
647        | "mcp_list_resource_templates"
648        | "mcp_read_resource"
649        | "mcp_list_prompts"
650        | "mcp_get_prompt"
651        | "mcp_server_info"
652        | "mcp_disconnect" => {
653            if !policy_allows_tool(&policy, "run")
654                || !policy_allows_capability(&policy, "process", "exec")
655                || !policy_allows_side_effect(&policy, "process_exec")
656            {
657                return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
658            }
659        }
660        "host_call" => {
661            let name = args.first().map(|v| v.display()).unwrap_or_default();
662            let Some((capability, op)) = name.split_once('.') else {
663                return reject_policy(format!(
664                    "host_call '{name}' must use capability.operation naming"
665                ));
666            };
667            if !policy_allows_capability(&policy, capability, op) {
668                return reject_policy(format!(
669                    "host_call {capability}.{op} exceeds capability ceiling"
670                ));
671            }
672            let requested_side_effect = match (capability, op) {
673                ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
674                ("process", "exec") => "process_exec",
675                _ => "read_only",
676            };
677            if !policy_allows_side_effect(&policy, requested_side_effect) {
678                return reject_policy(format!(
679                    "host_call {capability}.{op} exceeds side-effect ceiling"
680                ));
681            }
682        }
683        _ => {}
684    }
685    Ok(())
686}
687
688pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
689    if current_execution_policy().is_some() {
690        return reject_policy(format!(
691            "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
692        ));
693    }
694    Ok(())
695}
696
697pub fn enforce_current_policy_for_tool(tool_name: &str) -> Result<(), VmError> {
698    let Some(policy) = current_execution_policy() else {
699        return Ok(());
700    };
701    if !policy_allows_tool(&policy, tool_name) {
702        return reject_policy(format!("tool '{tool_name}' exceeds tool ceiling"));
703    }
704    if let Some(metadata) = policy.tool_metadata.get(tool_name) {
705        for (capability, ops) in &metadata.capabilities {
706            for op in ops {
707                if !policy_allows_capability(&policy, capability, op) {
708                    return reject_policy(format!(
709                        "tool '{tool_name}' exceeds capability ceiling: {capability}.{op}"
710                    ));
711                }
712            }
713        }
714        if let Some(side_effect_level) = metadata.side_effect_level.as_deref() {
715            if !policy_allows_side_effect(&policy, side_effect_level) {
716                return reject_policy(format!(
717                    "tool '{tool_name}' exceeds side-effect ceiling: {side_effect_level}"
718                ));
719            }
720        }
721    }
722    Ok(())
723}
724
725// ── Transcript policy helpers ───────────────────────────────────────
726
727fn compact_transcript(transcript: &VmValue, keep_last: usize) -> Option<VmValue> {
728    let dict = transcript.as_dict()?;
729    let messages = match dict.get("messages") {
730        Some(VmValue::List(list)) => list.iter().cloned().collect::<Vec<_>>(),
731        _ => Vec::new(),
732    };
733    let retained = messages
734        .into_iter()
735        .rev()
736        .take(keep_last)
737        .collect::<Vec<_>>()
738        .into_iter()
739        .rev()
740        .collect::<Vec<_>>();
741    let mut compacted = dict.clone();
742    compacted.insert(
743        "messages".to_string(),
744        VmValue::List(Rc::new(retained.clone())),
745    );
746    compacted.insert(
747        "events".to_string(),
748        VmValue::List(Rc::new(
749            crate::llm::helpers::transcript_events_from_messages(&retained),
750        )),
751    );
752    Some(VmValue::Dict(Rc::new(compacted)))
753}
754
755fn redact_transcript_visibility(transcript: &VmValue, visibility: Option<&str>) -> Option<VmValue> {
756    let Some(visibility) = visibility else {
757        return Some(transcript.clone());
758    };
759    if visibility != "public" && visibility != "public_only" {
760        return Some(transcript.clone());
761    }
762    let dict = transcript.as_dict()?;
763    let public_messages = match dict.get("messages") {
764        Some(VmValue::List(list)) => list
765            .iter()
766            .filter(|message| {
767                message
768                    .as_dict()
769                    .and_then(|d| d.get("role"))
770                    .map(|v| v.display())
771                    .map(|role| role != "tool_result")
772                    .unwrap_or(true)
773            })
774            .cloned()
775            .collect::<Vec<_>>(),
776        _ => Vec::new(),
777    };
778    let public_events = match dict.get("events") {
779        Some(VmValue::List(list)) => list
780            .iter()
781            .filter(|event| {
782                event
783                    .as_dict()
784                    .and_then(|d| d.get("visibility"))
785                    .map(|v| v.display())
786                    .map(|value| value == "public")
787                    .unwrap_or(true)
788            })
789            .cloned()
790            .collect::<Vec<_>>(),
791        _ => Vec::new(),
792    };
793    let mut redacted = dict.clone();
794    redacted.insert(
795        "messages".to_string(),
796        VmValue::List(Rc::new(public_messages)),
797    );
798    redacted.insert("events".to_string(), VmValue::List(Rc::new(public_events)));
799    Some(VmValue::Dict(Rc::new(redacted)))
800}
801
802pub(crate) fn apply_input_transcript_policy(
803    transcript: Option<VmValue>,
804    policy: &TranscriptPolicy,
805) -> Option<VmValue> {
806    let mut transcript = transcript;
807    match policy.mode.as_deref() {
808        Some("reset") => return None,
809        Some("fork") => {
810            if let Some(VmValue::Dict(dict)) = transcript.as_ref() {
811                let mut forked = dict.as_ref().clone();
812                forked.insert(
813                    "id".to_string(),
814                    VmValue::String(Rc::from(new_id("transcript"))),
815                );
816                transcript = Some(VmValue::Dict(Rc::new(forked)));
817            }
818        }
819        _ => {}
820    }
821    if policy.compact {
822        let keep_last = policy.keep_last.unwrap_or(6);
823        transcript = transcript.and_then(|value| compact_transcript(&value, keep_last));
824    }
825    transcript
826}
827
828pub(crate) fn apply_output_transcript_policy(
829    transcript: Option<VmValue>,
830    policy: &TranscriptPolicy,
831) -> Option<VmValue> {
832    let mut transcript = transcript;
833    if policy.compact {
834        let keep_last = policy.keep_last.unwrap_or(6);
835        transcript = transcript.and_then(|value| compact_transcript(&value, keep_last));
836    }
837    transcript.and_then(|value| redact_transcript_visibility(&value, policy.visibility.as_deref()))
838}
839
840pub fn builtin_ceiling() -> CapabilityPolicy {
841    CapabilityPolicy {
842        // Capabilities left empty — the host capability manifest is the sole
843        // authority on which operations are available.  An explicit allowlist
844        // here would silently block any capability the host adds later.
845        tools: Vec::new(),
846        capabilities: BTreeMap::new(),
847        workspace_roots: Vec::new(),
848        side_effect_level: Some("network".to_string()),
849        recursion_limit: Some(8),
850        tool_arg_constraints: Vec::new(),
851        tool_metadata: BTreeMap::new(),
852    }
853}
854
855#[cfg(test)]
856mod turn_policy_tests {
857    use super::TurnPolicy;
858
859    #[test]
860    fn default_allows_done_sentinel() {
861        let policy = TurnPolicy::default();
862        assert!(policy.allow_done_sentinel);
863        assert!(!policy.require_action_or_yield);
864        assert!(policy.max_prose_chars.is_none());
865    }
866
867    #[test]
868    fn deserializing_partial_dict_preserves_done_sentinel_pathway() {
869        // Pre-existing workflows passed `turn_policy: { require_action_or_yield: true }`
870        // without knowing about `allow_done_sentinel`. Deserializing such a dict
871        // must keep the done-sentinel pathway enabled so persistent agent loops
872        // don't lose their completion signal in this release.
873        let policy: TurnPolicy =
874            serde_json::from_value(serde_json::json!({ "require_action_or_yield": true }))
875                .expect("deserialize");
876        assert!(policy.require_action_or_yield);
877        assert!(policy.allow_done_sentinel);
878    }
879
880    #[test]
881    fn deserializing_explicit_false_disables_done_sentinel() {
882        let policy: TurnPolicy = serde_json::from_value(serde_json::json!({
883            "require_action_or_yield": true,
884            "allow_done_sentinel": false,
885        }))
886        .expect("deserialize");
887        assert!(policy.require_action_or_yield);
888        assert!(!policy.allow_done_sentinel);
889    }
890}