1mod types;
4
5use std::cell::RefCell;
6use std::collections::BTreeMap;
7use std::rc::Rc;
8use std::thread_local;
9
10use serde::{Deserialize, Serialize};
11
12use super::glob_match;
13use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
14use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
15use crate::triggers::dispatcher::current_dispatch_context;
16use crate::trust_graph::AutonomyTier;
17use crate::value::{VmError, VmValue};
18use crate::workspace_path::{classify_workspace_path, WorkspacePathInfo};
19
20pub use crate::tool_annotations::{ToolArgSchema, ToolKind};
21pub use types::{
22 enforce_tool_arg_constraints, AutoCompactPolicy, BranchSemantics, CapabilityPolicy,
23 ContextPolicy, EqIgnored, EscalationPolicy, JoinPolicy, MapPolicy, ModelPolicy, ReducePolicy,
24 RetryPolicy, StageContract, ToolArgConstraint, TurnPolicy,
25};
26
27thread_local! {
28 static EXECUTION_POLICY_STACK: RefCell<Vec<CapabilityPolicy>> = const { RefCell::new(Vec::new()) };
29 static EXECUTION_APPROVAL_POLICY_STACK: RefCell<Vec<ToolApprovalPolicy>> = const { RefCell::new(Vec::new()) };
30 static TRUSTED_BRIDGE_CALL_DEPTH: RefCell<usize> = const { RefCell::new(0) };
31}
32
33pub fn push_execution_policy(policy: CapabilityPolicy) {
34 EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
35}
36
37pub fn pop_execution_policy() {
38 EXECUTION_POLICY_STACK.with(|stack| {
39 stack.borrow_mut().pop();
40 });
41}
42
43pub fn current_execution_policy() -> Option<CapabilityPolicy> {
44 EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
45}
46
47pub fn push_approval_policy(policy: ToolApprovalPolicy) {
48 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
49}
50
51pub fn pop_approval_policy() {
52 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| {
53 stack.borrow_mut().pop();
54 });
55}
56
57pub fn current_approval_policy() -> Option<ToolApprovalPolicy> {
58 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
59}
60
61pub fn current_tool_annotations(tool: &str) -> Option<ToolAnnotations> {
62 current_execution_policy().and_then(|policy| policy.tool_annotations.get(tool).cloned())
63}
64
65fn tool_kind_participates_in_write_allowlist(tool_name: &str) -> bool {
66 current_tool_annotations(tool_name)
67 .map(|annotations| !annotations.kind.is_read_only())
68 .unwrap_or(true)
69}
70
71pub struct TrustedBridgeCallGuard;
72
73pub fn allow_trusted_bridge_calls() -> TrustedBridgeCallGuard {
74 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
75 *depth.borrow_mut() += 1;
76 });
77 TrustedBridgeCallGuard
78}
79
80impl Drop for TrustedBridgeCallGuard {
81 fn drop(&mut self) {
82 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
83 let mut depth = depth.borrow_mut();
84 *depth = depth.saturating_sub(1);
85 });
86 }
87}
88
89fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
90 policy.tools.is_empty() || policy.tools.iter().any(|allowed| allowed == tool)
91}
92
93fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
94 policy.capabilities.is_empty()
95 || policy
96 .capabilities
97 .get(capability)
98 .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
99}
100
101fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
102 fn rank(v: &str) -> usize {
103 match v {
104 "none" => 0,
105 "read_only" => 1,
106 "workspace_write" => 2,
107 "process_exec" => 3,
108 "network" => 4,
109 _ => 5,
110 }
111 }
112 policy
113 .side_effect_level
114 .as_ref()
115 .map(|allowed| rank(allowed) >= rank(requested))
116 .unwrap_or(true)
117}
118
119pub(super) fn reject_policy(reason: String) -> Result<(), VmError> {
120 Err(VmError::CategorizedError {
121 message: reason,
122 category: crate::value::ErrorCategory::ToolRejected,
123 })
124}
125
126pub fn current_tool_mutation_classification(tool_name: &str) -> String {
131 current_tool_annotations(tool_name)
132 .map(|annotations| annotations.kind.mutation_class().to_string())
133 .unwrap_or_else(|| "other".to_string())
134}
135
136pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
140 current_tool_declared_path_entries(tool_name, args)
141 .into_iter()
142 .map(|entry| entry.display_path().to_string())
143 .collect()
144}
145
146pub fn current_tool_declared_path_entries(
151 tool_name: &str,
152 args: &serde_json::Value,
153) -> Vec<WorkspacePathInfo> {
154 let Some(map) = args.as_object() else {
155 return Vec::new();
156 };
157 let Some(annotations) = current_tool_annotations(tool_name) else {
158 return Vec::new();
159 };
160 let workspace_root = crate::stdlib::process::execution_root_path();
161 let mut entries = Vec::new();
162 for key in &annotations.arg_schema.path_params {
163 if let Some(value) = map.get(key) {
164 match value {
165 serde_json::Value::String(path) if !path.is_empty() => {
166 entries.push(classify_workspace_path(path, Some(&workspace_root)));
167 }
168 serde_json::Value::Array(items) => {
169 for item in items.iter().filter_map(|item| item.as_str()) {
170 if !item.is_empty() {
171 entries.push(classify_workspace_path(item, Some(&workspace_root)));
172 }
173 }
174 }
175 _ => {}
176 }
177 }
178 }
179 entries.sort_by(|a, b| a.display_path().cmp(b.display_path()));
180 entries.dedup_by(|left, right| left.policy_candidates() == right.policy_candidates());
181 entries
182}
183
184fn builtin_mutates_state(name: &str) -> bool {
185 matches!(
186 name,
187 "write_file"
188 | "append_file"
189 | "mkdir"
190 | "copy_file"
191 | "delete_file"
192 | "apply_edit"
193 | "exec"
194 | "exec_at"
195 | "shell"
196 | "shell_at"
197 | "host_call"
198 | "store_set"
199 | "store_delete"
200 | "store_save"
201 | "store_clear"
202 | "metadata_set"
203 | "metadata_save"
204 | "metadata_refresh_hashes"
205 | "invalidate_facts"
206 | "checkpoint"
207 | "checkpoint_delete"
208 | "checkpoint_clear"
209 | "__agent_state_write"
210 | "__agent_state_delete"
211 | "__agent_state_handoff"
212 | "mcp_release"
213 )
214}
215
216fn emit_autonomy_proposal_event(
217 tier: AutonomyTier,
218 builtin_name: &str,
219 args: &[VmValue],
220) -> Result<(), VmError> {
221 let Some(context) = current_dispatch_context() else {
222 return Ok(());
223 };
224 let Some(log) = active_event_log() else {
225 return Ok(());
226 };
227 let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
228 .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
229 let mut headers = BTreeMap::new();
230 headers.insert(
231 "trace_id".to_string(),
232 context.trigger_event.trace_id.0.clone(),
233 );
234 headers.insert("agent".to_string(), context.agent_id.clone());
235 headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
236 let payload = serde_json::json!({
237 "agent": context.agent_id,
238 "action": context.action,
239 "builtin": builtin_name,
240 "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
241 "trace_id": context.trigger_event.trace_id.0,
242 "replay_of_event_id": context.replay_of_event_id,
243 "autonomy_tier": tier,
244 "proposal": true,
245 });
246 futures::executor::block_on(log.append(
247 &topic,
248 LogEvent::new("dispatch_proposed", payload).with_headers(headers),
249 ))
250 .map(|_| ())
251 .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
252}
253
254fn enforce_dispatch_autonomy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
255 let Some(context) = current_dispatch_context() else {
256 return Ok(());
257 };
258 if !builtin_mutates_state(name) {
259 return Ok(());
260 }
261 match context.autonomy_tier {
262 AutonomyTier::ActAuto => Ok(()),
263 AutonomyTier::ActWithApproval => reject_policy(format!(
264 "mutating builtin '{name}' is blocked for autonomy tier 'act_with_approval'; route the action through an approval-aware host/tool path"
265 )),
266 AutonomyTier::Shadow => {
267 emit_autonomy_proposal_event(AutonomyTier::Shadow, name, args)?;
268 reject_policy(format!(
269 "mutating builtin '{name}' is blocked for autonomy tier 'shadow'; proposal event emitted instead"
270 ))
271 }
272 AutonomyTier::Suggest => {
273 emit_autonomy_proposal_event(AutonomyTier::Suggest, name, args)?;
274 reject_policy(format!(
275 "mutating builtin '{name}' is blocked for autonomy tier 'suggest'; proposal event emitted and approval is required"
276 ))
277 }
278 }
279}
280
281pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
282 enforce_dispatch_autonomy_for_builtin(name, args)?;
283 let Some(policy) = current_execution_policy() else {
284 return Ok(());
285 };
286 match name {
287 "read_file" if !policy_allows_capability(&policy, "workspace", "read_text") => {
288 return reject_policy(format!(
289 "builtin '{name}' exceeds workspace.read_text ceiling"
290 ));
291 }
292 "list_dir" if !policy_allows_capability(&policy, "workspace", "list") => {
293 return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
294 }
295 "file_exists" | "stat" if !policy_allows_capability(&policy, "workspace", "exists") => {
296 return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
297 }
298 "write_file" | "append_file" | "mkdir" | "copy_file"
299 if !policy_allows_capability(&policy, "workspace", "write_text")
300 || !policy_allows_side_effect(&policy, "workspace_write") =>
301 {
302 return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
303 }
304 "delete_file"
305 if !policy_allows_capability(&policy, "workspace", "delete")
306 || !policy_allows_side_effect(&policy, "workspace_write") =>
307 {
308 return reject_policy(
309 "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
310 );
311 }
312 "apply_edit"
313 if !policy_allows_capability(&policy, "workspace", "apply_edit")
314 || !policy_allows_side_effect(&policy, "workspace_write") =>
315 {
316 return reject_policy(
317 "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
318 );
319 }
320 "exec" | "exec_at" | "shell" | "shell_at"
321 if !policy_allows_capability(&policy, "process", "exec")
322 || !policy_allows_side_effect(&policy, "process_exec") =>
323 {
324 return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
325 }
326 "http_get" | "http_post" | "http_put" | "http_patch" | "http_delete" | "http_request"
327 if !policy_allows_side_effect(&policy, "network") =>
328 {
329 return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
330 }
331 "mcp_connect"
332 | "mcp_call"
333 | "mcp_list_tools"
334 | "mcp_list_resources"
335 | "mcp_list_resource_templates"
336 | "mcp_read_resource"
337 | "mcp_list_prompts"
338 | "mcp_get_prompt"
339 | "mcp_server_info"
340 | "mcp_disconnect"
341 if !policy_allows_capability(&policy, "process", "exec")
342 || !policy_allows_side_effect(&policy, "process_exec") =>
343 {
344 return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
345 }
346 "host_call" => {
347 let name = args.first().map(|v| v.display()).unwrap_or_default();
348 let Some((capability, op)) = name.split_once('.') else {
349 return reject_policy(format!(
350 "host_call '{name}' must use capability.operation naming"
351 ));
352 };
353 if !policy_allows_capability(&policy, capability, op) {
354 return reject_policy(format!(
355 "host_call {capability}.{op} exceeds capability ceiling"
356 ));
357 }
358 let requested_side_effect = match (capability, op) {
359 ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
360 ("process", "exec") => "process_exec",
361 _ => "read_only",
362 };
363 if !policy_allows_side_effect(&policy, requested_side_effect) {
364 return reject_policy(format!(
365 "host_call {capability}.{op} exceeds side-effect ceiling"
366 ));
367 }
368 }
369 _ => {}
370 }
371 Ok(())
372}
373
374pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
375 let trusted = TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow() > 0);
376 if trusted {
377 return Ok(());
378 }
379 if current_execution_policy().is_some() {
380 return reject_policy(format!(
381 "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
382 ));
383 }
384 Ok(())
385}
386
387pub fn enforce_current_policy_for_tool(tool_name: &str) -> Result<(), VmError> {
388 let Some(policy) = current_execution_policy() else {
389 return Ok(());
390 };
391 if !policy_allows_tool(&policy, tool_name) {
392 return reject_policy(format!("tool '{tool_name}' exceeds tool ceiling"));
393 }
394 if let Some(annotations) = policy.tool_annotations.get(tool_name) {
395 for (capability, ops) in &annotations.capabilities {
396 for op in ops {
397 if !policy_allows_capability(&policy, capability, op) {
398 return reject_policy(format!(
399 "tool '{tool_name}' exceeds capability ceiling: {capability}.{op}"
400 ));
401 }
402 }
403 }
404 let requested_level = annotations.side_effect_level;
405 if requested_level != SideEffectLevel::None
406 && !policy_allows_side_effect(&policy, requested_level.as_str())
407 {
408 return reject_policy(format!(
409 "tool '{tool_name}' exceeds side-effect ceiling: {}",
410 requested_level.as_str()
411 ));
412 }
413 }
414 Ok(())
415}
416
417pub fn redact_transcript_visibility(
429 transcript: &VmValue,
430 visibility: Option<&str>,
431) -> Option<VmValue> {
432 let Some(visibility) = visibility else {
433 return Some(transcript.clone());
434 };
435 if visibility != "public" && visibility != "public_only" {
436 return Some(transcript.clone());
437 }
438 let dict = transcript.as_dict()?;
439 let public_messages = match dict.get("messages") {
440 Some(VmValue::List(list)) => list
441 .iter()
442 .filter(|message| {
443 message
444 .as_dict()
445 .and_then(|d| d.get("role"))
446 .map(|v| v.display())
447 .map(|role| role != "tool_result")
448 .unwrap_or(true)
449 })
450 .cloned()
451 .collect::<Vec<_>>(),
452 _ => Vec::new(),
453 };
454 let public_events = match dict.get("events") {
455 Some(VmValue::List(list)) => list
456 .iter()
457 .filter(|event| {
458 event
459 .as_dict()
460 .and_then(|d| d.get("visibility"))
461 .map(|v| v.display())
462 .map(|value| value == "public")
463 .unwrap_or(true)
464 })
465 .cloned()
466 .collect::<Vec<_>>(),
467 _ => Vec::new(),
468 };
469 let mut redacted = dict.clone();
470 redacted.insert(
471 "messages".to_string(),
472 VmValue::List(Rc::new(public_messages)),
473 );
474 redacted.insert("events".to_string(), VmValue::List(Rc::new(public_events)));
475 Some(VmValue::Dict(Rc::new(redacted)))
476}
477
478pub fn builtin_ceiling() -> CapabilityPolicy {
479 CapabilityPolicy {
480 tools: Vec::new(),
484 capabilities: BTreeMap::new(),
485 workspace_roots: Vec::new(),
486 side_effect_level: Some("network".to_string()),
487 recursion_limit: Some(8),
488 tool_arg_constraints: Vec::new(),
489 tool_annotations: BTreeMap::new(),
490 }
491}
492
493#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
497#[serde(default)]
498pub struct ToolApprovalPolicy {
499 #[serde(default)]
501 pub auto_approve: Vec<String>,
502 #[serde(default)]
504 pub auto_deny: Vec<String>,
505 #[serde(default)]
507 pub require_approval: Vec<String>,
508 #[serde(default)]
510 pub write_path_allowlist: Vec<String>,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum ToolApprovalDecision {
516 AutoApproved,
518 AutoDenied { reason: String },
520 RequiresHostApproval,
523}
524
525impl ToolApprovalPolicy {
526 pub fn evaluate(&self, tool_name: &str, args: &serde_json::Value) -> ToolApprovalDecision {
529 for pattern in &self.auto_deny {
531 if glob_match(pattern, tool_name) {
532 return ToolApprovalDecision::AutoDenied {
533 reason: format!("tool '{tool_name}' matches deny pattern '{pattern}'"),
534 };
535 }
536 }
537
538 if !self.write_path_allowlist.is_empty()
539 && tool_kind_participates_in_write_allowlist(tool_name)
540 {
541 let paths = super::current_tool_declared_path_entries(tool_name, args);
542 for path in &paths {
543 let allowed = self.write_path_allowlist.iter().any(|pattern| {
544 path.policy_candidates()
545 .iter()
546 .any(|candidate| glob_match(pattern, candidate))
547 });
548 if !allowed {
549 return ToolApprovalDecision::AutoDenied {
550 reason: format!(
551 "tool '{tool_name}' targets '{}' which is not in the write-path allowlist",
552 path.display_path()
553 ),
554 };
555 }
556 }
557 }
558
559 for pattern in &self.auto_approve {
560 if glob_match(pattern, tool_name) {
561 return ToolApprovalDecision::AutoApproved;
562 }
563 }
564
565 for pattern in &self.require_approval {
566 if glob_match(pattern, tool_name) {
567 return ToolApprovalDecision::RequiresHostApproval;
568 }
569 }
570
571 ToolApprovalDecision::AutoApproved
572 }
573
574 pub fn intersect(&self, other: &ToolApprovalPolicy) -> ToolApprovalPolicy {
580 let auto_approve = if self.auto_approve.is_empty() {
581 other.auto_approve.clone()
582 } else if other.auto_approve.is_empty() {
583 self.auto_approve.clone()
584 } else {
585 self.auto_approve
586 .iter()
587 .filter(|p| other.auto_approve.contains(p))
588 .cloned()
589 .collect()
590 };
591 let mut auto_deny = self.auto_deny.clone();
592 auto_deny.extend(other.auto_deny.iter().cloned());
593 let mut require_approval = self.require_approval.clone();
594 require_approval.extend(other.require_approval.iter().cloned());
595 let write_path_allowlist = if self.write_path_allowlist.is_empty() {
596 other.write_path_allowlist.clone()
597 } else if other.write_path_allowlist.is_empty() {
598 self.write_path_allowlist.clone()
599 } else {
600 self.write_path_allowlist
601 .iter()
602 .filter(|p| other.write_path_allowlist.contains(p))
603 .cloned()
604 .collect()
605 };
606 ToolApprovalPolicy {
607 auto_approve,
608 auto_deny,
609 require_approval,
610 write_path_allowlist,
611 }
612 }
613}
614
615#[cfg(test)]
616mod approval_policy_tests {
617 use super::*;
618 use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
619 use crate::tool_annotations::{ToolAnnotations, ToolArgSchema, ToolKind};
620
621 #[test]
622 fn auto_deny_takes_precedence_over_auto_approve() {
623 let policy = ToolApprovalPolicy {
624 auto_approve: vec!["*".to_string()],
625 auto_deny: vec!["dangerous_*".to_string()],
626 ..Default::default()
627 };
628 assert_eq!(
629 policy.evaluate("dangerous_rm", &serde_json::json!({})),
630 ToolApprovalDecision::AutoDenied {
631 reason: "tool 'dangerous_rm' matches deny pattern 'dangerous_*'".to_string()
632 }
633 );
634 }
635
636 #[test]
637 fn auto_approve_matches_glob() {
638 let policy = ToolApprovalPolicy {
639 auto_approve: vec!["read*".to_string(), "search*".to_string()],
640 ..Default::default()
641 };
642 assert_eq!(
643 policy.evaluate("read_file", &serde_json::json!({})),
644 ToolApprovalDecision::AutoApproved
645 );
646 assert_eq!(
647 policy.evaluate("search", &serde_json::json!({})),
648 ToolApprovalDecision::AutoApproved
649 );
650 }
651
652 #[test]
653 fn require_approval_emits_decision() {
654 let policy = ToolApprovalPolicy {
655 require_approval: vec!["edit*".to_string()],
656 ..Default::default()
657 };
658 let decision = policy.evaluate("edit_file", &serde_json::json!({"path": "foo.rs"}));
659 assert!(matches!(
660 decision,
661 ToolApprovalDecision::RequiresHostApproval
662 ));
663 }
664
665 #[test]
666 fn unmatched_tool_defaults_to_approved() {
667 let policy = ToolApprovalPolicy {
668 auto_approve: vec!["read*".to_string()],
669 require_approval: vec!["edit*".to_string()],
670 ..Default::default()
671 };
672 assert_eq!(
673 policy.evaluate("unknown_tool", &serde_json::json!({})),
674 ToolApprovalDecision::AutoApproved
675 );
676 }
677
678 #[test]
679 fn intersect_merges_deny_lists() {
680 let a = ToolApprovalPolicy {
681 auto_deny: vec!["rm*".to_string()],
682 ..Default::default()
683 };
684 let b = ToolApprovalPolicy {
685 auto_deny: vec!["drop*".to_string()],
686 ..Default::default()
687 };
688 let merged = a.intersect(&b);
689 assert_eq!(merged.auto_deny.len(), 2);
690 }
691
692 #[test]
693 fn intersect_restricts_auto_approve_to_common_patterns() {
694 let a = ToolApprovalPolicy {
695 auto_approve: vec!["read*".to_string(), "search*".to_string()],
696 ..Default::default()
697 };
698 let b = ToolApprovalPolicy {
699 auto_approve: vec!["read*".to_string(), "write*".to_string()],
700 ..Default::default()
701 };
702 let merged = a.intersect(&b);
703 assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
704 }
705
706 #[test]
707 fn intersect_defers_auto_approve_when_one_side_empty() {
708 let a = ToolApprovalPolicy {
709 auto_approve: vec!["read*".to_string()],
710 ..Default::default()
711 };
712 let b = ToolApprovalPolicy::default();
713 let merged = a.intersect(&b);
714 assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
715 }
716
717 #[test]
718 fn write_path_allowlist_matches_recovered_workspace_relative_path() {
719 let temp = tempfile::tempdir().unwrap();
720 std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
721 std::fs::write(temp.path().join("packages/demo/file.txt"), "ok").unwrap();
722 crate::stdlib::process::set_thread_execution_context(Some(
723 crate::orchestration::RunExecutionRecord {
724 cwd: Some(temp.path().to_string_lossy().into_owned()),
725 source_dir: Some(temp.path().to_string_lossy().into_owned()),
726 env: BTreeMap::new(),
727 adapter: None,
728 repo_path: None,
729 worktree_path: None,
730 branch: None,
731 base_ref: None,
732 cleanup: None,
733 },
734 ));
735
736 let mut tool_annotations = BTreeMap::new();
737 tool_annotations.insert(
738 "write_file".to_string(),
739 ToolAnnotations {
740 kind: ToolKind::Edit,
741 arg_schema: ToolArgSchema {
742 path_params: vec!["path".to_string()],
743 ..Default::default()
744 },
745 ..Default::default()
746 },
747 );
748 push_execution_policy(CapabilityPolicy {
749 tool_annotations,
750 ..Default::default()
751 });
752
753 let policy = ToolApprovalPolicy {
754 write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
755 ..Default::default()
756 };
757 let decision = policy.evaluate(
758 "write_file",
759 &serde_json::json!({"path": "/packages/demo/file.txt"}),
760 );
761 assert_eq!(decision, ToolApprovalDecision::AutoApproved);
762
763 pop_execution_policy();
764 crate::stdlib::process::set_thread_execution_context(None);
765 }
766
767 #[test]
768 fn write_path_allowlist_does_not_block_read_only_tools() {
769 let temp = tempfile::tempdir().unwrap();
770 std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
771 std::fs::write(temp.path().join("packages/demo/context.txt"), "ok").unwrap();
772 crate::stdlib::process::set_thread_execution_context(Some(
773 crate::orchestration::RunExecutionRecord {
774 cwd: Some(temp.path().to_string_lossy().into_owned()),
775 source_dir: Some(temp.path().to_string_lossy().into_owned()),
776 env: BTreeMap::new(),
777 adapter: None,
778 repo_path: None,
779 worktree_path: None,
780 branch: None,
781 base_ref: None,
782 cleanup: None,
783 },
784 ));
785
786 let mut tool_annotations = BTreeMap::new();
787 tool_annotations.insert(
788 "read_file".to_string(),
789 ToolAnnotations {
790 kind: ToolKind::Read,
791 arg_schema: ToolArgSchema {
792 path_params: vec!["path".to_string()],
793 ..Default::default()
794 },
795 ..Default::default()
796 },
797 );
798 push_execution_policy(CapabilityPolicy {
799 tool_annotations,
800 ..Default::default()
801 });
802
803 let policy = ToolApprovalPolicy {
804 write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
805 ..Default::default()
806 };
807 let decision = policy.evaluate(
808 "read_file",
809 &serde_json::json!({"path": "/packages/demo/context.txt"}),
810 );
811 assert_eq!(decision, ToolApprovalDecision::AutoApproved);
812
813 pop_execution_policy();
814 crate::stdlib::process::set_thread_execution_context(None);
815 }
816}
817
818#[cfg(test)]
819mod turn_policy_tests {
820 use super::TurnPolicy;
821
822 #[test]
823 fn default_allows_done_sentinel() {
824 let policy = TurnPolicy::default();
825 assert!(policy.allow_done_sentinel);
826 assert!(!policy.require_action_or_yield);
827 assert!(policy.max_prose_chars.is_none());
828 }
829
830 #[test]
831 fn deserializing_partial_dict_preserves_done_sentinel_pathway() {
832 let policy: TurnPolicy =
837 serde_json::from_value(serde_json::json!({ "require_action_or_yield": true }))
838 .expect("deserialize");
839 assert!(policy.require_action_or_yield);
840 assert!(policy.allow_done_sentinel);
841 }
842
843 #[test]
844 fn deserializing_explicit_false_disables_done_sentinel() {
845 let policy: TurnPolicy = serde_json::from_value(serde_json::json!({
846 "require_action_or_yield": true,
847 "allow_done_sentinel": false,
848 }))
849 .expect("deserialize");
850 assert!(policy.require_action_or_yield);
851 assert!(!policy.allow_done_sentinel);
852 }
853}
854
855#[cfg(test)]
856mod visibility_redaction_tests {
857 use super::*;
858 use crate::value::VmValue;
859
860 fn mock_transcript() -> VmValue {
861 let messages = vec![
862 serde_json::json!({"role": "user", "content": "hi"}),
863 serde_json::json!({"role": "assistant", "content": "hello"}),
864 serde_json::json!({"role": "tool_result", "content": "internal tool output"}),
865 ];
866 crate::llm::helpers::transcript_to_vm_with_events(
867 Some("test-id".to_string()),
868 None,
869 None,
870 &messages,
871 Vec::new(),
872 Vec::new(),
873 Some("active"),
874 )
875 }
876
877 fn message_count(transcript: &VmValue) -> usize {
878 transcript
879 .as_dict()
880 .and_then(|d| d.get("messages"))
881 .and_then(|v| match v {
882 VmValue::List(list) => Some(list.len()),
883 _ => None,
884 })
885 .unwrap_or(0)
886 }
887
888 #[test]
889 fn visibility_none_returns_unchanged() {
890 let t = mock_transcript();
891 let result = redact_transcript_visibility(&t, None).unwrap();
892 assert_eq!(message_count(&result), 3);
893 }
894
895 #[test]
896 fn visibility_public_drops_tool_results() {
897 let t = mock_transcript();
898 let result = redact_transcript_visibility(&t, Some("public")).unwrap();
899 assert_eq!(message_count(&result), 2);
900 }
901
902 #[test]
903 fn visibility_unknown_string_is_pass_through() {
904 let t = mock_transcript();
905 let result = redact_transcript_visibility(&t, Some("internal")).unwrap();
906 assert_eq!(message_count(&result), 3);
907 }
908}