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 ModelPolicy {
238    pub provider: Option<String>,
239    pub model: Option<String>,
240    pub model_tier: Option<String>,
241    pub temperature: Option<f64>,
242    pub max_tokens: Option<i64>,
243    /// Maximum agent_loop iterations for this stage. Overrides the default 16.
244    pub max_iterations: Option<usize>,
245    /// Maximum consecutive text-only (no tool call) responses before declaring stuck.
246    pub max_nudges: Option<usize>,
247    /// Custom nudge message injected when the model produces text without tool calls.
248    /// If omitted, the VM uses a generic "Continue — use a tool call" message.
249    pub nudge: Option<String>,
250    /// Few-shot tool-call examples injected into the tool contract prompt,
251    /// shown before the tool schema listing. Pipelines provide these —
252    /// the VM has no hardcoded tool names.
253    pub tool_examples: Option<String>,
254    /// Optional Harn closure called after each tool-calling turn.
255    /// Receives turn metadata; returns an optional user message to inject.
256    /// Wrapped in EqIgnored so it doesn't affect PartialEq derivation.
257    #[serde(skip)]
258    pub post_turn_callback: Option<EqIgnored<VmValue>>,
259}
260
261/// Wrapper that always compares equal, allowing non-Eq types in derived PartialEq structs.
262#[derive(Clone, Debug, Default)]
263pub struct EqIgnored<T>(pub T);
264
265impl<T> PartialEq for EqIgnored<T> {
266    fn eq(&self, _: &Self) -> bool {
267        true
268    }
269}
270
271impl<T> std::ops::Deref for EqIgnored<T> {
272    type Target = T;
273    fn deref(&self) -> &T {
274        &self.0
275    }
276}
277
278#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
279#[serde(default)]
280pub struct TranscriptPolicy {
281    pub mode: Option<String>,
282    pub visibility: Option<String>,
283    pub summarize: bool,
284    pub compact: bool,
285    pub keep_last: Option<usize>,
286    /// Enable per-turn auto-compaction within agent loops.
287    pub auto_compact: bool,
288    /// Token threshold for tier-1 compaction.
289    pub compact_threshold: Option<usize>,
290    /// Max chars per tool result before compression.
291    pub tool_output_max_chars: Option<usize>,
292    /// Tier-1 compaction strategy name (e.g., "observation_mask", "llm").
293    pub compact_strategy: Option<String>,
294    /// Token threshold for tier-2 aggressive compaction.
295    pub hard_limit_tokens: Option<usize>,
296    /// Tier-2 compaction strategy name.
297    pub hard_limit_strategy: Option<String>,
298}
299
300#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
301#[serde(default)]
302pub struct ContextPolicy {
303    pub max_artifacts: Option<usize>,
304    pub max_tokens: Option<usize>,
305    pub reserve_tokens: Option<usize>,
306    pub include_kinds: Vec<String>,
307    pub exclude_kinds: Vec<String>,
308    pub prioritize_kinds: Vec<String>,
309    pub pinned_ids: Vec<String>,
310    pub include_stages: Vec<String>,
311    pub prefer_recent: bool,
312    pub prefer_fresh: bool,
313    pub render: Option<String>,
314}
315
316#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
317#[serde(default)]
318pub struct RetryPolicy {
319    pub max_attempts: usize,
320    pub verify: bool,
321    pub repair: bool,
322    /// Initial backoff duration in milliseconds between retry attempts.
323    /// When `None`, retries proceed without delay.
324    #[serde(default)]
325    pub backoff_ms: Option<u64>,
326    /// Multiplier applied to `backoff_ms` after each retry attempt.
327    /// Defaults to 2.0 when `backoff_ms` is set and this field is `None`.
328    #[serde(default)]
329    pub backoff_multiplier: Option<f64>,
330}
331
332#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
333#[serde(default)]
334pub struct StageContract {
335    pub input_kinds: Vec<String>,
336    pub output_kinds: Vec<String>,
337    pub min_inputs: Option<usize>,
338    pub max_inputs: Option<usize>,
339    pub require_transcript: bool,
340    pub schema: Option<serde_json::Value>,
341}
342
343#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(default)]
345pub struct BranchSemantics {
346    pub success: Option<String>,
347    pub failure: Option<String>,
348    pub verify_pass: Option<String>,
349    pub verify_fail: Option<String>,
350    pub condition_true: Option<String>,
351    pub condition_false: Option<String>,
352    pub loop_continue: Option<String>,
353    pub loop_exit: Option<String>,
354    pub escalation: Option<String>,
355}
356
357#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
358#[serde(default)]
359pub struct MapPolicy {
360    pub items: Vec<serde_json::Value>,
361    pub item_artifact_kind: Option<String>,
362    pub output_kind: Option<String>,
363    pub max_items: Option<usize>,
364}
365
366#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(default)]
368pub struct JoinPolicy {
369    pub strategy: String,
370    pub require_all_inputs: bool,
371    pub min_completed: Option<usize>,
372}
373
374#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
375#[serde(default)]
376pub struct ReducePolicy {
377    pub strategy: String,
378    pub separator: Option<String>,
379    pub output_kind: Option<String>,
380}
381
382#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
383#[serde(default)]
384pub struct EscalationPolicy {
385    pub level: Option<String>,
386    pub queue: Option<String>,
387    pub reason: Option<String>,
388}
389
390// ── Execution policy stack ──────────────────────────────────────────
391
392pub fn push_execution_policy(policy: CapabilityPolicy) {
393    EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
394}
395
396pub fn pop_execution_policy() {
397    EXECUTION_POLICY_STACK.with(|stack| {
398        stack.borrow_mut().pop();
399    });
400}
401
402pub fn current_execution_policy() -> Option<CapabilityPolicy> {
403    EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
404}
405
406pub fn current_tool_metadata(tool: &str) -> Option<ToolRuntimePolicyMetadata> {
407    current_execution_policy().and_then(|policy| policy.tool_metadata.get(tool).cloned())
408}
409
410fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
411    policy.tools.is_empty() || policy.tools.iter().any(|allowed| allowed == tool)
412}
413
414fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
415    policy.capabilities.is_empty()
416        || policy
417            .capabilities
418            .get(capability)
419            .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
420}
421
422fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
423    fn rank(v: &str) -> usize {
424        match v {
425            "none" => 0,
426            "read_only" => 1,
427            "workspace_write" => 2,
428            "process_exec" => 3,
429            "network" => 4,
430            _ => 5,
431        }
432    }
433    policy
434        .side_effect_level
435        .as_ref()
436        .map(|allowed| rank(allowed) >= rank(requested))
437        .unwrap_or(true)
438}
439
440fn reject_policy(reason: String) -> Result<(), VmError> {
441    Err(VmError::CategorizedError {
442        message: reason,
443        category: crate::value::ErrorCategory::ToolRejected,
444    })
445}
446
447fn fallback_mutation_classification(tool_name: &str) -> String {
448    let lower = tool_name.to_ascii_lowercase();
449    if lower.starts_with("mcp_") {
450        return "host_defined".to_string();
451    }
452    if lower == "exec"
453        || lower == "shell"
454        || lower == "exec_at"
455        || lower == "shell_at"
456        || lower == "run"
457        || lower.starts_with("run_")
458    {
459        return "ambient_side_effect".to_string();
460    }
461    if lower.starts_with("delete")
462        || lower.starts_with("remove")
463        || lower.starts_with("move")
464        || lower.starts_with("rename")
465    {
466        return "destructive".to_string();
467    }
468    if lower.contains("write")
469        || lower.contains("edit")
470        || lower.contains("patch")
471        || lower.contains("create")
472        || lower.contains("scaffold")
473        || lower.starts_with("insert")
474        || lower.starts_with("replace")
475        || lower == "add_import"
476    {
477        return "apply_workspace".to_string();
478    }
479    "read_only".to_string()
480}
481
482pub fn current_tool_mutation_classification(tool_name: &str) -> String {
483    current_tool_metadata(tool_name)
484        .and_then(|metadata| metadata.mutation_classification)
485        .unwrap_or_else(|| fallback_mutation_classification(tool_name))
486}
487
488pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
489    let Some(map) = args.as_object() else {
490        return Vec::new();
491    };
492    let path_keys = current_tool_metadata(tool_name)
493        .map(|metadata| metadata.path_params)
494        .filter(|keys| !keys.is_empty())
495        .unwrap_or_else(|| {
496            vec![
497                "path".to_string(),
498                "file".to_string(),
499                "cwd".to_string(),
500                "repo".to_string(),
501                "target".to_string(),
502                "destination".to_string(),
503            ]
504        });
505    let mut paths = Vec::new();
506    for key in path_keys {
507        if let Some(value) = map.get(&key).and_then(|value| value.as_str()) {
508            if !value.is_empty() {
509                paths.push(value.to_string());
510            }
511        }
512    }
513    if let Some(items) = map.get("paths").and_then(|value| value.as_array()) {
514        for item in items {
515            if let Some(value) = item.as_str() {
516                if !value.is_empty() {
517                    paths.push(value.to_string());
518                }
519            }
520        }
521    }
522    paths.sort();
523    paths.dedup();
524    paths
525}
526
527pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
528    let Some(policy) = current_execution_policy() else {
529        return Ok(());
530    };
531    match name {
532        "read" | "read_file" => {
533            if !policy_allows_tool(&policy, name)
534                || !policy_allows_capability(&policy, "workspace", "read_text")
535            {
536                return reject_policy(format!(
537                    "builtin '{name}' exceeds workspace.read_text ceiling"
538                ));
539            }
540        }
541        "search" | "list_dir" => {
542            if !policy_allows_tool(&policy, name)
543                || !policy_allows_capability(&policy, "workspace", "list")
544            {
545                return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
546            }
547        }
548        "file_exists" | "stat" => {
549            if !policy_allows_capability(&policy, "workspace", "exists") {
550                return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
551            }
552        }
553        "edit" | "write_file" | "append_file" | "mkdir" | "copy_file" => {
554            if !policy_allows_tool(&policy, "edit")
555                || !policy_allows_capability(&policy, "workspace", "write_text")
556                || !policy_allows_side_effect(&policy, "workspace_write")
557            {
558                return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
559            }
560        }
561        "delete_file" => {
562            if !policy_allows_capability(&policy, "workspace", "delete")
563                || !policy_allows_side_effect(&policy, "workspace_write")
564            {
565                return reject_policy(
566                    "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
567                );
568            }
569        }
570        "apply_edit" => {
571            if !policy_allows_capability(&policy, "workspace", "apply_edit")
572                || !policy_allows_side_effect(&policy, "workspace_write")
573            {
574                return reject_policy(
575                    "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
576                );
577            }
578        }
579        "exec" | "exec_at" | "shell" | "shell_at" | "run_command" => {
580            if !policy_allows_tool(&policy, "run")
581                || !policy_allows_capability(&policy, "process", "exec")
582                || !policy_allows_side_effect(&policy, "process_exec")
583            {
584                return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
585            }
586        }
587        "http_get" | "http_post" | "http_put" | "http_patch" | "http_delete" | "http_request" => {
588            if !policy_allows_side_effect(&policy, "network") {
589                return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
590            }
591        }
592        "mcp_connect"
593        | "mcp_call"
594        | "mcp_list_tools"
595        | "mcp_list_resources"
596        | "mcp_list_resource_templates"
597        | "mcp_read_resource"
598        | "mcp_list_prompts"
599        | "mcp_get_prompt"
600        | "mcp_server_info"
601        | "mcp_disconnect" => {
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        "host_call" => {
610            let name = args.first().map(|v| v.display()).unwrap_or_default();
611            let Some((capability, op)) = name.split_once('.') else {
612                return reject_policy(format!(
613                    "host_call '{name}' must use capability.operation naming"
614                ));
615            };
616            if !policy_allows_capability(&policy, capability, op) {
617                return reject_policy(format!(
618                    "host_call {capability}.{op} exceeds capability ceiling"
619                ));
620            }
621            let requested_side_effect = match (capability, op) {
622                ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
623                ("process", "exec") => "process_exec",
624                _ => "read_only",
625            };
626            if !policy_allows_side_effect(&policy, requested_side_effect) {
627                return reject_policy(format!(
628                    "host_call {capability}.{op} exceeds side-effect ceiling"
629                ));
630            }
631        }
632        _ => {}
633    }
634    Ok(())
635}
636
637pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
638    if current_execution_policy().is_some() {
639        return reject_policy(format!(
640            "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
641        ));
642    }
643    Ok(())
644}
645
646pub fn enforce_current_policy_for_tool(tool_name: &str) -> Result<(), VmError> {
647    let Some(policy) = current_execution_policy() else {
648        return Ok(());
649    };
650    if !policy_allows_tool(&policy, tool_name) {
651        return reject_policy(format!("tool '{tool_name}' exceeds tool ceiling"));
652    }
653    if let Some(metadata) = policy.tool_metadata.get(tool_name) {
654        for (capability, ops) in &metadata.capabilities {
655            for op in ops {
656                if !policy_allows_capability(&policy, capability, op) {
657                    return reject_policy(format!(
658                        "tool '{tool_name}' exceeds capability ceiling: {capability}.{op}"
659                    ));
660                }
661            }
662        }
663        if let Some(side_effect_level) = metadata.side_effect_level.as_deref() {
664            if !policy_allows_side_effect(&policy, side_effect_level) {
665                return reject_policy(format!(
666                    "tool '{tool_name}' exceeds side-effect ceiling: {side_effect_level}"
667                ));
668            }
669        }
670    }
671    Ok(())
672}
673
674// ── Transcript policy helpers ───────────────────────────────────────
675
676fn compact_transcript(transcript: &VmValue, keep_last: usize) -> Option<VmValue> {
677    let dict = transcript.as_dict()?;
678    let messages = match dict.get("messages") {
679        Some(VmValue::List(list)) => list.iter().cloned().collect::<Vec<_>>(),
680        _ => Vec::new(),
681    };
682    let retained = messages
683        .into_iter()
684        .rev()
685        .take(keep_last)
686        .collect::<Vec<_>>()
687        .into_iter()
688        .rev()
689        .collect::<Vec<_>>();
690    let mut compacted = dict.clone();
691    compacted.insert(
692        "messages".to_string(),
693        VmValue::List(Rc::new(retained.clone())),
694    );
695    compacted.insert(
696        "events".to_string(),
697        VmValue::List(Rc::new(
698            crate::llm::helpers::transcript_events_from_messages(&retained),
699        )),
700    );
701    Some(VmValue::Dict(Rc::new(compacted)))
702}
703
704fn redact_transcript_visibility(transcript: &VmValue, visibility: Option<&str>) -> Option<VmValue> {
705    let Some(visibility) = visibility else {
706        return Some(transcript.clone());
707    };
708    if visibility != "public" && visibility != "public_only" {
709        return Some(transcript.clone());
710    }
711    let dict = transcript.as_dict()?;
712    let public_messages = match dict.get("messages") {
713        Some(VmValue::List(list)) => list
714            .iter()
715            .filter(|message| {
716                message
717                    .as_dict()
718                    .and_then(|d| d.get("role"))
719                    .map(|v| v.display())
720                    .map(|role| role != "tool_result")
721                    .unwrap_or(true)
722            })
723            .cloned()
724            .collect::<Vec<_>>(),
725        _ => Vec::new(),
726    };
727    let public_events = match dict.get("events") {
728        Some(VmValue::List(list)) => list
729            .iter()
730            .filter(|event| {
731                event
732                    .as_dict()
733                    .and_then(|d| d.get("visibility"))
734                    .map(|v| v.display())
735                    .map(|value| value == "public")
736                    .unwrap_or(true)
737            })
738            .cloned()
739            .collect::<Vec<_>>(),
740        _ => Vec::new(),
741    };
742    let mut redacted = dict.clone();
743    redacted.insert(
744        "messages".to_string(),
745        VmValue::List(Rc::new(public_messages)),
746    );
747    redacted.insert("events".to_string(), VmValue::List(Rc::new(public_events)));
748    Some(VmValue::Dict(Rc::new(redacted)))
749}
750
751pub(crate) fn apply_input_transcript_policy(
752    transcript: Option<VmValue>,
753    policy: &TranscriptPolicy,
754) -> Option<VmValue> {
755    let mut transcript = transcript;
756    match policy.mode.as_deref() {
757        Some("reset") => return None,
758        Some("fork") => {
759            if let Some(VmValue::Dict(dict)) = transcript.as_ref() {
760                let mut forked = dict.as_ref().clone();
761                forked.insert(
762                    "id".to_string(),
763                    VmValue::String(Rc::from(new_id("transcript"))),
764                );
765                transcript = Some(VmValue::Dict(Rc::new(forked)));
766            }
767        }
768        _ => {}
769    }
770    if policy.compact {
771        let keep_last = policy.keep_last.unwrap_or(6);
772        transcript = transcript.and_then(|value| compact_transcript(&value, keep_last));
773    }
774    transcript
775}
776
777pub(crate) fn apply_output_transcript_policy(
778    transcript: Option<VmValue>,
779    policy: &TranscriptPolicy,
780) -> Option<VmValue> {
781    let mut transcript = transcript;
782    if policy.compact {
783        let keep_last = policy.keep_last.unwrap_or(6);
784        transcript = transcript.and_then(|value| compact_transcript(&value, keep_last));
785    }
786    transcript.and_then(|value| redact_transcript_visibility(&value, policy.visibility.as_deref()))
787}
788
789pub fn builtin_ceiling() -> CapabilityPolicy {
790    CapabilityPolicy {
791        // Capabilities left empty — the host capability manifest is the sole
792        // authority on which operations are available.  An explicit allowlist
793        // here would silently block any capability the host adds later.
794        tools: Vec::new(),
795        capabilities: BTreeMap::new(),
796        workspace_roots: Vec::new(),
797        side_effect_level: Some("network".to_string()),
798        recursion_limit: Some(8),
799        tool_arg_constraints: Vec::new(),
800        tool_metadata: BTreeMap::new(),
801    }
802}