1use 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#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(default)]
22pub struct ToolArgConstraint {
23 pub tool: String,
25 pub arg_patterns: Vec<String>,
28}
29
30pub 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 #[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 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 pub require_action_or_yield: bool,
248 #[serde(default = "default_true")]
252 pub allow_done_sentinel: bool,
253 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 pub max_iterations: Option<usize>,
283 pub max_nudges: Option<usize>,
285 pub nudge: Option<String>,
288 pub tool_examples: Option<String>,
292 #[serde(skip)]
297 pub post_turn_callback: Option<EqIgnored<VmValue>>,
298 pub stop_after_successful_tools: Option<Vec<String>>,
303 pub require_successful_tools: Option<Vec<String>>,
307 pub turn_policy: Option<TurnPolicy>,
309}
310
311#[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 pub auto_compact: bool,
338 pub compact_threshold: Option<usize>,
340 pub tool_output_max_chars: Option<usize>,
342 pub compact_strategy: Option<String>,
344 pub hard_limit_tokens: Option<usize>,
346 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 #[serde(default)]
375 pub backoff_ms: Option<u64>,
376 #[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
441pub 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
725fn 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 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 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}