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