1mod approval_rules;
4mod effects;
5mod nested_budget;
6mod operator_grant;
7pub(crate) mod tool_enforcement;
8mod types;
9
10use crate::value::VmDictExt;
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::thread_local;
14
15use serde::{Deserialize, Serialize};
16
17use crate::runtime_limits::RuntimeLimits;
18use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
19use crate::value::{VmError, VmValue};
20use crate::workspace_path::{classify_workspace_path, WorkspacePathInfo};
21
22pub use crate::tool_annotations::{ToolArgSchema, ToolKind};
23pub use approval_rules::{
24 clear_all_approval_policy_repeat_counts, clear_approval_policy_repeat_counts,
25 next_approval_policy_repeat_count, next_approval_unavailable_class_repeat_count, ApprovalShape,
26 PolicyAction, PolicyEvaluation, PolicyMatchedRule, PolicyRule, PolicyRuleMatch,
27 ToolApprovalRequest,
28};
29pub use effects::{
30 compute_handoff_effects, effect_kind_label, effect_record_summary, effect_subset_violations,
31 effects_from_metadata, EffectKind, EffectRecord, EffectScope,
32};
33pub(crate) use effects::{effect_allowed_by_ceiling, runtime_effects_from_contract};
34pub use nested_budget::{
35 annotate_nested_execution_options, enter_nested_execution_policy, NestedExecutionGuard,
36 NestedExecutionKind, NESTED_KIND_OPTION_KEY, NESTED_LABEL_OPTION_KEY,
37};
38pub(crate) use operator_grant::{
39 clear_operator_approval_grants, swap_operator_approval_grant_stack,
40};
41pub use operator_grant::{
42 current_operator_approval_grant, install_operator_approval_grant, OperatorApprovalGrant,
43 OperatorApprovalGrantGuard,
44};
45pub use tool_enforcement::enforce_current_policy_for_tool;
46pub(crate) use tool_enforcement::enforce_current_policy_for_tool_with_annotations_and_side_effect_grant;
47pub use types::{
48 enforce_tool_arg_constraints, AutoCompactPolicy, BranchSemantics, CapabilityPolicy,
49 ContextPolicy, EqIgnored, EscalationPolicy, FeedbackBounds, FeedbackPolicy, JoinPolicy,
50 MapPolicy, ModelPolicy, NativeToolFallbackPolicy, ProcessSandboxPolicy, ProcessSandboxPreset,
51 ReducePolicy, RequiredSuccessfulTool, RetryPolicy, SandboxProfile, StageContract,
52 ToolArgConstraint, TurnPolicy,
53};
54
55thread_local! {
56 static EXECUTION_POLICY_STACK: RefCell<Vec<CapabilityPolicy>> = const { RefCell::new(Vec::new()) };
57 static EXECUTION_APPROVAL_POLICY_STACK: RefCell<Vec<ToolApprovalPolicy>> = const { RefCell::new(Vec::new()) };
58 static TRUSTED_BRIDGE_CALL_DEPTH: RefCell<usize> = const { RefCell::new(0) };
59}
60
61pub fn push_execution_policy(policy: CapabilityPolicy) {
62 EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
63}
64
65pub fn pop_execution_policy() {
66 EXECUTION_POLICY_STACK.with(|stack| {
67 stack.borrow_mut().pop();
68 });
69}
70
71pub fn clear_execution_policy_stacks() {
72 EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
73 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
74 clear_operator_approval_grants();
75 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow_mut() = 0);
76}
77
78pub fn current_execution_policy() -> Option<CapabilityPolicy> {
79 EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
80}
81
82pub fn execution_policy_active() -> bool {
87 EXECUTION_POLICY_STACK.with(|stack| !stack.borrow().is_empty())
88}
89
90pub fn push_approval_policy(policy: ToolApprovalPolicy) {
91 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
92}
93
94pub fn pop_approval_policy() {
95 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| {
96 stack.borrow_mut().pop();
97 });
98}
99
100pub fn current_approval_policy() -> Option<ToolApprovalPolicy> {
101 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
102}
103
104pub(crate) fn swap_execution_policy_stack(next: Vec<CapabilityPolicy>) -> Vec<CapabilityPolicy> {
117 EXECUTION_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
118}
119
120pub(crate) fn swap_approval_policy_stack(next: Vec<ToolApprovalPolicy>) -> Vec<ToolApprovalPolicy> {
121 EXECUTION_APPROVAL_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
122}
123
124pub(crate) fn swap_trusted_bridge_depth(next: usize) -> usize {
125 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| std::mem::replace(&mut *depth.borrow_mut(), next))
126}
127
128pub fn current_tool_annotations(tool: &str) -> Option<ToolAnnotations> {
129 current_execution_policy().and_then(|policy| policy.tool_annotations.get(tool).cloned())
130}
131
132pub fn current_allowed_tool_names() -> Vec<String> {
140 let Some(policy) = current_execution_policy() else {
141 return Vec::new();
142 };
143 if policy.tools_are_restricted() {
144 return policy.allowed_tool_patterns().map(str::to_string).collect();
145 }
146 policy.tool_annotations.keys().cloned().collect()
147}
148
149pub(super) fn tool_kind_participates_in_write_allowlist(tool_name: &str) -> bool {
150 current_tool_annotations(tool_name)
151 .map(|annotations| !annotations.kind.is_read_only())
152 .unwrap_or(true)
153}
154
155pub struct TrustedBridgeCallGuard;
156
157pub fn allow_trusted_bridge_calls() -> TrustedBridgeCallGuard {
158 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
159 *depth.borrow_mut() += 1;
160 });
161 TrustedBridgeCallGuard
162}
163
164impl Drop for TrustedBridgeCallGuard {
165 fn drop(&mut self) {
166 TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
167 let mut depth = depth.borrow_mut();
168 *depth = depth.saturating_sub(1);
169 });
170 }
171}
172
173fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
174 policy.tool_pattern_allows(tool)
175}
176
177fn policy_grants_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
178 policy
179 .capabilities
180 .get(capability)
181 .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
182}
183
184fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
185 if !policy.capabilities_are_restricted() {
186 return true;
188 }
189 if policy.capabilities_deny_all() {
190 return false;
191 }
192 if policy_grants_capability(policy, capability, op) {
193 return true;
194 }
195 if capability == "workspace" && op == "exists" {
207 return policy_grants_capability(policy, "workspace", "read_text")
208 || policy_grants_capability(policy, "workspace", "list");
209 }
210 false
211}
212
213fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
214 let requested_rank = SideEffectLevel::rank_str(requested);
220 policy
221 .side_effect_level
222 .as_ref()
223 .map(|allowed| SideEffectLevel::rank_str(allowed) >= requested_rank)
224 .unwrap_or(true)
225}
226
227pub(super) fn reject_policy(reason: String) -> Result<(), VmError> {
228 Err(VmError::CategorizedError {
229 message: reason,
230 category: crate::value::ErrorCategory::ToolRejected,
231 })
232}
233
234#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct PolicyDenial {
242 pub gate: crate::agent_events::DenialGate,
243 pub capability: Option<String>,
244 pub reason: String,
245 pub side_effect_ceiling: Option<SideEffectCeilingViolation>,
250}
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub struct SideEffectCeilingViolation {
257 pub ceiling: SideEffectLevel,
258 pub required_level: SideEffectLevel,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq)]
265pub(crate) struct SideEffectCeilingGrant {
266 tool_name: String,
267 violation: SideEffectCeilingViolation,
268}
269
270impl PolicyDenial {
271 pub(crate) fn side_effect_grant_for(&self, tool_name: &str) -> Option<SideEffectCeilingGrant> {
273 self.side_effect_ceiling
274 .map(|violation| SideEffectCeilingGrant {
275 tool_name: tool_name.to_string(),
276 violation,
277 })
278 }
279}
280
281impl SideEffectCeilingGrant {
282 fn matches(&self, tool_name: &str, violation: SideEffectCeilingViolation) -> bool {
283 self.tool_name == tool_name && self.violation == violation
284 }
285}
286
287impl From<PolicyDenial> for VmError {
288 fn from(denial: PolicyDenial) -> Self {
289 VmError::CategorizedError {
290 message: denial.reason,
291 category: crate::value::ErrorCategory::ToolRejected,
292 }
293 }
294}
295
296pub(super) fn reject_tool(
297 gate: crate::agent_events::DenialGate,
298 capability: Option<String>,
299 particulars: String,
300) -> Result<(), PolicyDenial> {
301 Err(PolicyDenial {
302 gate,
303 capability,
304 reason: gate.render_reason(particulars),
305 side_effect_ceiling: None,
306 })
307}
308
309pub fn current_tool_mutation_classification(tool_name: &str) -> String {
314 current_tool_annotations(tool_name)
315 .map(|annotations| annotations.kind.mutation_class().to_string())
316 .unwrap_or_else(|| "other".to_string())
317}
318
319pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
323 current_tool_declared_path_entries(tool_name, args)
324 .into_iter()
325 .map(|entry| entry.display_path().to_string())
326 .collect()
327}
328
329pub fn current_tool_declared_path_entries(
334 tool_name: &str,
335 args: &serde_json::Value,
336) -> Vec<WorkspacePathInfo> {
337 let Some(annotations) = current_tool_annotations(tool_name) else {
338 return Vec::new();
339 };
340 tool_declared_path_entries(&annotations, args)
341}
342
343pub fn tool_declared_path_entries(
349 annotations: &crate::tool_annotations::ToolAnnotations,
350 args: &serde_json::Value,
351) -> Vec<WorkspacePathInfo> {
352 let Some(map) = args.as_object() else {
353 return Vec::new();
354 };
355 let workspace_root = crate::stdlib::process::execution_root_path();
356 let mut entries = Vec::new();
357 for key in &annotations.arg_schema.path_params {
358 if let Some(value) = map.get(key) {
359 match value {
360 serde_json::Value::String(path) if !path.is_empty() => {
361 entries.push(classify_workspace_path(path, Some(&workspace_root)));
362 }
363 serde_json::Value::Array(items) => {
364 for item in items.iter().filter_map(|item| item.as_str()) {
365 if !item.is_empty() {
366 entries.push(classify_workspace_path(item, Some(&workspace_root)));
367 }
368 }
369 }
370 _ => {}
371 }
372 }
373 }
374 entries.sort_by(|a, b| a.display_path().cmp(b.display_path()));
375 entries.dedup_by(|left, right| left.policy_candidates() == right.policy_candidates());
376 entries
377}
378
379pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
380 let Some(policy) = current_execution_policy() else {
381 return Ok(());
382 };
383 if let Some(entry) = crate::stdlib::builtin_manifest_entry(name) {
384 if let harn_builtin_meta::BuiltinExposure::CapabilityFunction { authority_argument } =
385 entry.contract.exposure
386 {
387 if args.get(usize::from(authority_argument)).is_none() {
388 return reject_policy(format!(
389 "capability function '{name}' is missing authority argument {authority_argument}"
390 ));
391 }
392 if let Some(effect) =
393 effects::runtime_effects_from_contract(entry.contract.effects, args)
394 .into_iter()
395 .find(|effect| !effects::effect_allowed_by_ceiling(effect, &policy))
396 {
397 return reject_policy(format!(
398 "capability function '{name}' exceeds the active effect ceiling: {}",
399 effects::effect_record_summary(&effect)
400 ));
401 }
402 return Ok(());
403 }
404 }
405 if effects::builtin_has_network_effect(name)
406 && (!policy_allows_capability(&policy, "network", "http")
407 || !policy_allows_side_effect(&policy, "network"))
408 {
409 return reject_policy(format!("builtin '{name}' exceeds network.http ceiling"));
410 }
411 match name {
412 "find_text" | "find_evidence"
413 if !policy_allows_capability(&policy, "workspace", "read_text")
414 || !policy_allows_capability(&policy, "workspace", "list") =>
415 {
416 return reject_policy(format!(
417 "builtin '{name}' exceeds workspace.read_text/workspace.list ceiling"
418 ));
419 }
420 "read_file"
421 | "read_file_result"
422 | "read_file_bytes"
423 | "package_snapshot_open"
424 | "render"
425 | "render_prompt"
426 | "render_with_provenance"
427 | "read_lines"
428 if !policy_allows_capability(&policy, "workspace", "read_text") =>
429 {
430 return reject_policy(format!(
431 "builtin '{name}' exceeds workspace.read_text ceiling"
432 ));
433 }
434 "list_dir" | "walk_dir" | "glob"
435 if !policy_allows_capability(&policy, "workspace", "list") =>
436 {
437 return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
438 }
439 "file_exists" | "path_status" | "stat"
440 if !policy_allows_capability(&policy, "workspace", "exists") =>
441 {
442 return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
443 }
444 "write_file"
445 | "write_file_bytes"
446 | "replace_file"
447 | "replace_file_result"
448 | "replace_file_bytes"
449 | "replace_file_bytes_result"
450 | "append_file"
451 | "append_file_locked"
452 | "mkdir"
453 | "copy_file"
454 | "move_file"
455 if !policy_allows_capability(&policy, "workspace", "write_text")
456 || !policy_allows_side_effect(&policy, "workspace_write") =>
457 {
458 return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
459 }
460 "delete_file"
461 if !policy_allows_capability(&policy, "workspace", "delete")
462 || !policy_allows_side_effect(&policy, "workspace_write") =>
463 {
464 return reject_policy(
465 "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
466 );
467 }
468 "apply_edit"
469 if !policy_allows_capability(&policy, "workspace", "apply_edit")
470 || !policy_allows_side_effect(&policy, "workspace_write") =>
471 {
472 return reject_policy(
473 "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
474 );
475 }
476 "exec"
477 | "exec_at"
478 | "shell"
479 | "shell_at"
480 | "git.repo.discover"
481 | "git.worktree.create"
482 | "git.worktree.remove"
483 | "git.fetch"
484 | "git.rebase"
485 | "git.status"
486 | "git.conflicts"
487 | "git.push"
488 | "git.diff"
489 | "git.merge_base"
490 | "git.tag_list"
491 | "git.describe"
492 | "git.ls_remote"
493 if !policy_allows_capability(&policy, "process", "exec")
494 || !policy_allows_side_effect(&policy, "process_exec") =>
495 {
496 return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
497 }
498 "__files_upload" if !policy_allows_capability(&policy, "workspace", "read_text") => {
503 return reject_policy(
504 "builtin '__files_upload' exceeds workspace.read_text/network ceiling".to_string(),
505 );
506 }
507 "llm_call" | "llm_call_safe" | "llm_completion" | "llm_stream" | "llm_stream_call"
508 | "llm_healthcheck" | "agent_loop"
509 if !policy_allows_capability(&policy, "llm", "call") =>
510 {
511 return reject_policy(format!("builtin '{name}' exceeds llm.call ceiling"));
512 }
513 "connector_call"
514 if !policy_allows_capability(&policy, "connector", "call")
515 || !policy_allows_side_effect(&policy, "network") =>
516 {
517 return reject_policy(
518 "builtin 'connector_call' exceeds connector.call/network ceiling".to_string(),
519 );
520 }
521 "secret_get" if !policy_allows_capability(&policy, "connector", "secret_get") => {
522 return reject_policy(
523 "builtin 'secret_get' exceeds connector.secret_get ceiling".to_string(),
524 );
525 }
526 "event_log_emit" if !policy_allows_capability(&policy, "connector", "event_log_emit") => {
527 return reject_policy(
528 "builtin 'event_log_emit' exceeds connector.event_log_emit ceiling".to_string(),
529 );
530 }
531 "metrics_inc" if !policy_allows_capability(&policy, "connector", "metrics_inc") => {
532 return reject_policy(
533 "builtin 'metrics_inc' exceeds connector.metrics_inc ceiling".to_string(),
534 );
535 }
536 "project_fingerprint"
537 | "project_context_profile_native"
538 | "project_scan_native"
539 | "project_scan_tree_native"
540 | "project_walk_tree_native"
541 | "project_catalog_native"
542 if !policy_allows_capability(&policy, "workspace", "list")
543 || !policy_allows_side_effect(&policy, "read_only") =>
544 {
545 return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
546 }
547 "__agent_state_init"
548 | "__agent_state_resume"
549 | "__agent_state_write"
550 | "__agent_state_read"
551 | "__agent_state_list"
552 | "__agent_state_delete"
553 | "__agent_state_handoff"
554 if !policy_allows_capability(&policy, "agent_state", "access") =>
555 {
556 return reject_policy(format!(
557 "builtin '{name}' exceeds agent_state.access ceiling"
558 ));
559 }
560 "vision_ocr"
561 if !policy_allows_capability(&policy, "vision", "ocr")
562 || !policy_allows_side_effect(&policy, "process_exec") =>
563 {
564 return reject_policy(format!(
565 "builtin '{name}' exceeds vision.ocr/process ceiling"
566 ));
567 }
568 "mcp_connect"
569 | "mcp_ensure_active"
570 | "mcp_call"
571 | "mcp_list_tools"
572 | "mcp_list_resources"
573 | "mcp_list_resource_templates"
574 | "mcp_read_resource"
575 | "mcp_list_prompts"
576 | "mcp_get_prompt"
577 | "mcp_server_info"
578 | "mcp_disconnect"
579 if !policy_allows_capability(&policy, "process", "exec")
580 || !policy_allows_side_effect(&policy, "process_exec") =>
581 {
582 return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
583 }
584 "host_call" => {
585 let name = args.first().map(|v| v.display()).unwrap_or_default();
586 let Some((capability, op)) = name.split_once('.') else {
587 return reject_policy(format!(
588 "host_call '{name}' must use capability.operation naming"
589 ));
590 };
591 if !policy_allows_capability(&policy, capability, op) {
592 return reject_policy(format!(
593 "host_call {capability}.{op} exceeds capability ceiling"
594 ));
595 }
596 let requested_side_effect = match (capability, op) {
597 ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
598 ("process", "exec") => "process_exec",
599 _ => "read_only",
600 };
601 if !policy_allows_side_effect(&policy, requested_side_effect) {
602 return reject_policy(format!(
603 "host_call {capability}.{op} exceeds side-effect ceiling"
604 ));
605 }
606 }
607 "host_tool_list" | "host_tool_call"
608 if !policy_allows_capability(&policy, "host", "tool_call") =>
609 {
610 return reject_policy(format!("builtin '{name}' exceeds host.tool_call ceiling"));
611 }
612 _ => {}
613 }
614 Ok(())
615}
616
617pub fn enforce_current_policy_for_capability(
619 capability: harn_builtin_meta::CapabilityId,
620 method: &str,
621 args: &[VmValue],
622) -> Result<(), VmError> {
623 let Some(policy) = current_execution_policy() else {
624 return Ok(());
625 };
626 let Some(entry) = crate::stdlib::capability_method_manifest_entry(capability, method) else {
627 return reject_policy(format!(
628 "undeclared Harness capability method `harness.{}.{method}`",
629 capability.field_name()
630 ));
631 };
632 let denied = effects::runtime_effects_from_contract(entry.contract.effects, args)
633 .into_iter()
634 .find(|effect| !effects::effect_allowed_by_ceiling(effect, &policy));
635 if let Some(effect) = denied {
636 return reject_policy(format!(
637 "harness.{}.{method} exceeds the active effect ceiling: {}",
638 capability.field_name(),
639 effects::effect_record_summary(&effect)
640 ));
641 }
642 Ok(())
643}
644
645pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
646 let trusted = TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow() > 0);
647 if trusted {
648 return Ok(());
649 }
650 if current_execution_policy().is_some() {
651 return reject_policy(format!(
652 "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
653 ));
654 }
655 Ok(())
656}
657
658pub fn redact_transcript_visibility(
670 transcript: &VmValue,
671 visibility: Option<&str>,
672) -> Option<VmValue> {
673 let Some(visibility) = visibility else {
674 return Some(transcript.clone());
675 };
676 if visibility != "public" && visibility != "public_only" {
677 return Some(transcript.clone());
678 }
679 let dict = transcript.as_dict()?;
680 let public_messages = match dict.get("messages") {
681 Some(VmValue::List(list)) => list
682 .iter()
683 .filter_map(redact_public_message)
684 .collect::<Vec<_>>(),
685 _ => Vec::new(),
686 };
687 let public_events = match dict.get("events") {
688 Some(VmValue::List(list)) => list
689 .iter()
690 .filter(|event| {
691 event
692 .as_dict()
693 .and_then(|d| d.get("visibility"))
694 .map(|v| v.display())
695 .map(|value| value == "public")
696 .unwrap_or(true)
697 })
698 .cloned()
699 .collect::<Vec<_>>(),
700 _ => Vec::new(),
701 };
702 let mut redacted = dict.clone();
703 redacted.insert(
704 crate::value::intern_key("messages"),
705 VmValue::List(std::sync::Arc::new(public_messages)),
706 );
707 redacted.insert(
708 crate::value::intern_key("events"),
709 VmValue::List(std::sync::Arc::new(public_events)),
710 );
711 Some(VmValue::dict(redacted))
712}
713
714fn redact_public_message(message: &VmValue) -> Option<VmValue> {
715 let Some(dict) = message.as_dict() else {
716 return Some(message.clone());
717 };
718 if dict.get("role").map(|value| value.display()).as_deref() == Some("tool_result") {
719 return None;
720 }
721 if dict
722 .get("visibility")
723 .map(|value| value.display())
724 .is_some_and(|visibility| visibility != "public")
725 {
726 return None;
727 }
728
729 let mut redacted = dict.clone();
730 let mut saw_structured_blocks = false;
731 let mut public_text = Vec::new();
732 for key in ["content", "blocks"] {
733 if let Some(VmValue::List(blocks)) = dict.get(key) {
734 saw_structured_blocks = true;
735 let public_blocks = blocks
736 .iter()
737 .filter_map(redact_public_block)
738 .collect::<Vec<_>>();
739 if key == "blocks" || public_text.is_empty() {
740 public_text = text_fragments_from_blocks(&public_blocks);
741 }
742 redacted.insert(
743 crate::value::intern_key(key),
744 VmValue::List(std::sync::Arc::new(public_blocks)),
745 );
746 }
747 }
748 if saw_structured_blocks {
749 if public_text.is_empty() {
750 redacted.remove("text");
751 } else {
752 redacted.put_str("text", public_text.join("\n"));
753 }
754 }
755 Some(VmValue::dict(redacted))
756}
757
758fn redact_public_block(block: &VmValue) -> Option<VmValue> {
759 let Some(dict) = block.as_dict() else {
760 return Some(block.clone());
761 };
762 if dict
763 .get("visibility")
764 .map(|value| value.display())
765 .is_some_and(|visibility| visibility != "public")
766 {
767 return None;
768 }
769 Some(block.clone())
770}
771
772fn text_fragments_from_blocks(blocks: &[VmValue]) -> Vec<String> {
773 blocks
774 .iter()
775 .filter_map(|block| block.as_dict())
776 .filter_map(|dict| dict.get("text"))
777 .filter_map(|text| match text {
778 VmValue::String(value) if !value.is_empty() => Some(value.to_string()),
779 _ => None,
780 })
781 .collect()
782}
783
784pub fn builtin_ceiling() -> CapabilityPolicy {
785 CapabilityPolicy {
786 tools: Vec::new(),
790 capabilities: BTreeMap::new(),
791 workspace_roots: Vec::new(),
792 read_only_roots: Vec::new(),
793 side_effect_level: Some(SideEffectLevel::MAX.as_str().to_string()),
803 recursion_limit: Some(RuntimeLimits::DEFAULT.max_nested_execution_depth),
804 tool_arg_constraints: Vec::new(),
805 tool_annotations: BTreeMap::new(),
806 sandbox_profile: SandboxProfile::Worktree,
807 process_sandbox: Default::default(),
808 }
809}
810
811#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
815#[serde(default)]
816pub struct ToolApprovalPolicy {
817 #[serde(default)]
820 pub rules: Vec<PolicyRule>,
821 #[serde(default)]
823 pub auto_approve: Vec<String>,
824 #[serde(default)]
826 pub auto_deny: Vec<String>,
827 #[serde(default)]
829 pub require_approval: Vec<String>,
830 #[serde(default)]
832 pub write_path_allowlist: Vec<String>,
833 #[serde(default)]
835 pub allow_sensitive_paths: bool,
836 #[serde(default)]
839 pub sensitive_path_patterns: Vec<String>,
840 #[serde(default)]
842 pub allow_external_paths: bool,
843 #[serde(default)]
845 pub external_roots: Vec<String>,
846 #[serde(default, alias = "repeated_call_limit")]
848 pub repeat_limit: Option<u64>,
849 #[serde(default, alias = "repeated_call_action")]
851 pub repeat_action: Option<PolicyAction>,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq)]
856pub enum ToolApprovalDecision {
857 AutoApproved,
859 AutoDenied { reason: String },
861 RequiresHostApproval,
864}
865
866impl ToolApprovalPolicy {
867 pub fn evaluate_detailed(&self, tool_name: &str, args: &serde_json::Value) -> PolicyEvaluation {
868 approval_rules::evaluate_tool_approval_policy(self, tool_name, args, None)
869 }
870
871 pub fn evaluate_detailed_with_repeat(
872 &self,
873 tool_name: &str,
874 args: &serde_json::Value,
875 repeat_count: u64,
876 ) -> PolicyEvaluation {
877 approval_rules::evaluate_tool_approval_policy(self, tool_name, args, Some(repeat_count))
878 }
879
880 pub fn evaluate(&self, tool_name: &str, args: &serde_json::Value) -> ToolApprovalDecision {
883 let decision = self.evaluate_detailed(tool_name, args);
884 if decision.is_deny() {
885 return ToolApprovalDecision::AutoDenied {
886 reason: decision.reason,
887 };
888 }
889 if decision.is_ask() {
890 return ToolApprovalDecision::RequiresHostApproval;
891 }
892 ToolApprovalDecision::AutoApproved
893 }
894
895 pub fn intersect(&self, other: &ToolApprovalPolicy) -> ToolApprovalPolicy {
901 let auto_approve = if self.auto_approve.is_empty() {
902 other.auto_approve.clone()
903 } else if other.auto_approve.is_empty() {
904 self.auto_approve.clone()
905 } else {
906 self.auto_approve
907 .iter()
908 .filter(|p| other.auto_approve.contains(p))
909 .cloned()
910 .collect()
911 };
912 let mut auto_deny = self.auto_deny.clone();
913 auto_deny.extend(other.auto_deny.iter().cloned());
914 let mut require_approval = self.require_approval.clone();
915 require_approval.extend(other.require_approval.iter().cloned());
916 let write_path_allowlist = if self.write_path_allowlist.is_empty() {
917 other.write_path_allowlist.clone()
918 } else if other.write_path_allowlist.is_empty() {
919 self.write_path_allowlist.clone()
920 } else {
921 self.write_path_allowlist
922 .iter()
923 .filter(|p| other.write_path_allowlist.contains(p))
924 .cloned()
925 .collect()
926 };
927 let mut rules = self.rules.clone();
928 rules.extend(other.rules.iter().cloned());
929 let mut sensitive_path_patterns = self.sensitive_path_patterns.clone();
930 sensitive_path_patterns.extend(other.sensitive_path_patterns.iter().cloned());
931 sensitive_path_patterns.sort();
932 sensitive_path_patterns.dedup();
933 let external_roots = if self.external_roots.is_empty() {
934 other.external_roots.clone()
935 } else if other.external_roots.is_empty() {
936 self.external_roots.clone()
937 } else {
938 self.external_roots
939 .iter()
940 .filter(|root| other.external_roots.contains(root))
941 .cloned()
942 .collect()
943 };
944 ToolApprovalPolicy {
945 rules,
946 auto_approve,
947 auto_deny,
948 require_approval,
949 write_path_allowlist,
950 allow_sensitive_paths: self.allow_sensitive_paths && other.allow_sensitive_paths,
951 sensitive_path_patterns,
952 allow_external_paths: self.allow_external_paths && other.allow_external_paths,
953 external_roots,
954 repeat_limit: match (self.repeat_limit, other.repeat_limit) {
955 (Some(left), Some(right)) => Some(left.min(right)),
956 (Some(left), None) => Some(left),
957 (None, Some(right)) => Some(right),
958 (None, None) => None,
959 },
960 repeat_action: match (self.repeat_action, other.repeat_action) {
961 (Some(PolicyAction::Deny), _) | (_, Some(PolicyAction::Deny)) => {
962 Some(PolicyAction::Deny)
963 }
964 (Some(PolicyAction::Ask), _) | (_, Some(PolicyAction::Ask)) => {
965 Some(PolicyAction::Ask)
966 }
967 (Some(PolicyAction::Allow), Some(PolicyAction::Allow)) => Some(PolicyAction::Allow),
968 (Some(action), None) | (None, Some(action)) => Some(action),
969 (None, None) => None,
970 },
971 }
972 }
973}
974
975#[cfg(test)]
976mod approval_policy_tests {
977 use super::*;
978 use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
979 use crate::tool_annotations::{ToolAnnotations, ToolArgSchema, ToolKind};
980
981 fn workspace_caps(ops: &[&str]) -> CapabilityPolicy {
982 CapabilityPolicy {
983 capabilities: std::collections::BTreeMap::from([(
984 "workspace".to_string(),
985 ops.iter().map(|s| s.to_string()).collect(),
986 )]),
987 ..Default::default()
988 }
989 }
990
991 #[test]
992 fn builtin_ceiling_permits_desktop_control_but_a_lower_ceiling_denies_it() {
993 let builtin = builtin_ceiling();
997 assert!(policy_allows_side_effect(
998 &builtin,
999 SideEffectLevel::DesktopControl.as_str()
1000 ));
1001
1002 let network_ceiling = CapabilityPolicy {
1006 side_effect_level: Some(SideEffectLevel::Network.as_str().to_string()),
1007 ..Default::default()
1008 };
1009 assert!(!policy_allows_side_effect(
1010 &network_ceiling,
1011 SideEffectLevel::DesktopControl.as_str()
1012 ));
1013 assert!(policy_allows_side_effect(
1015 &network_ceiling,
1016 SideEffectLevel::ProcessExec.as_str()
1017 ));
1018 }
1019
1020 #[test]
1021 fn read_text_subsumes_exists_probe() {
1022 push_execution_policy(workspace_caps(&[
1030 "read_text",
1031 "list",
1032 "write_text",
1033 "apply_edit",
1034 ]));
1035 assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_ok());
1036 assert!(enforce_current_policy_for_builtin("path_status", &[]).is_ok());
1037 assert!(enforce_current_policy_for_builtin("stat", &[]).is_ok());
1038 pop_execution_policy();
1039 }
1040
1041 #[test]
1042 fn list_alone_subsumes_exists_probe() {
1043 push_execution_policy(workspace_caps(&["list"]));
1045 assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_ok());
1046 assert!(enforce_current_policy_for_builtin("path_status", &[]).is_ok());
1047 pop_execution_policy();
1048 }
1049
1050 #[test]
1051 fn exists_probe_rejected_without_any_read_grant() {
1052 push_execution_policy(workspace_caps(&["write_text", "apply_edit"]));
1055 assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_err());
1056 assert!(enforce_current_policy_for_builtin("path_status", &[]).is_err());
1057 pop_execution_policy();
1058 }
1059
1060 #[test]
1061 fn auto_deny_takes_precedence_over_auto_approve() {
1062 let policy = ToolApprovalPolicy {
1063 auto_approve: vec!["*".to_string()],
1064 auto_deny: vec!["dangerous_*".to_string()],
1065 ..Default::default()
1066 };
1067 assert_eq!(
1068 policy.evaluate("dangerous_rm", &serde_json::json!({})),
1069 ToolApprovalDecision::AutoDenied {
1070 reason: "tool 'dangerous_rm' matches deny pattern 'dangerous_*'".to_string()
1071 }
1072 );
1073 }
1074
1075 #[test]
1076 fn auto_approve_matches_glob() {
1077 let policy = ToolApprovalPolicy {
1078 auto_approve: vec!["read*".to_string(), "search*".to_string()],
1079 ..Default::default()
1080 };
1081 assert_eq!(
1082 policy.evaluate("read_file", &serde_json::json!({})),
1083 ToolApprovalDecision::AutoApproved
1084 );
1085 assert_eq!(
1086 policy.evaluate("search", &serde_json::json!({})),
1087 ToolApprovalDecision::AutoApproved
1088 );
1089 }
1090
1091 #[test]
1092 fn require_approval_emits_decision() {
1093 let policy = ToolApprovalPolicy {
1094 require_approval: vec!["edit*".to_string()],
1095 ..Default::default()
1096 };
1097 let decision = policy.evaluate("edit_file", &serde_json::json!({"path": "foo.rs"}));
1098 assert!(matches!(
1099 decision,
1100 ToolApprovalDecision::RequiresHostApproval
1101 ));
1102 }
1103
1104 #[test]
1105 fn unmatched_tool_defaults_to_approved() {
1106 let policy = ToolApprovalPolicy {
1107 auto_approve: vec!["read*".to_string()],
1108 require_approval: vec!["edit*".to_string()],
1109 ..Default::default()
1110 };
1111 assert_eq!(
1112 policy.evaluate("unknown_tool", &serde_json::json!({})),
1113 ToolApprovalDecision::AutoApproved
1114 );
1115 }
1116
1117 #[test]
1118 fn intersect_merges_deny_lists() {
1119 let a = ToolApprovalPolicy {
1120 auto_deny: vec!["rm*".to_string()],
1121 ..Default::default()
1122 };
1123 let b = ToolApprovalPolicy {
1124 auto_deny: vec!["drop*".to_string()],
1125 ..Default::default()
1126 };
1127 let merged = a.intersect(&b);
1128 assert_eq!(merged.auto_deny.len(), 2);
1129 }
1130
1131 #[test]
1132 fn intersect_restricts_auto_approve_to_common_patterns() {
1133 let a = ToolApprovalPolicy {
1134 auto_approve: vec!["read*".to_string(), "search*".to_string()],
1135 ..Default::default()
1136 };
1137 let b = ToolApprovalPolicy {
1138 auto_approve: vec!["read*".to_string(), "write*".to_string()],
1139 ..Default::default()
1140 };
1141 let merged = a.intersect(&b);
1142 assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
1143 }
1144
1145 #[test]
1146 fn intersect_defers_auto_approve_when_one_side_empty() {
1147 let a = ToolApprovalPolicy {
1148 auto_approve: vec!["read*".to_string()],
1149 ..Default::default()
1150 };
1151 let b = ToolApprovalPolicy::default();
1152 let merged = a.intersect(&b);
1153 assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
1154 }
1155
1156 #[test]
1157 fn write_path_allowlist_matches_recovered_workspace_relative_path() {
1158 let temp = tempfile::tempdir().unwrap();
1159 std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
1160 std::fs::write(temp.path().join("packages/demo/file.txt"), "ok").unwrap();
1161 crate::stdlib::process::set_thread_execution_context(Some(
1162 crate::orchestration::RunExecutionRecord {
1163 cwd: Some(temp.path().to_string_lossy().into_owned()),
1164 project_root: None,
1165 source_dir: Some(temp.path().to_string_lossy().into_owned()),
1166 env: BTreeMap::new(),
1167 adapter: None,
1168 repo_path: None,
1169 worktree_path: None,
1170 branch: None,
1171 base_ref: None,
1172 cleanup: None,
1173 environment_policy: Default::default(),
1174 grants: Vec::new(),
1175 },
1176 ));
1177
1178 let mut tool_annotations = BTreeMap::new();
1179 tool_annotations.insert(
1180 "write_file".to_string(),
1181 ToolAnnotations {
1182 kind: ToolKind::Edit,
1183 arg_schema: ToolArgSchema {
1184 path_params: vec!["path".to_string()],
1185 ..Default::default()
1186 },
1187 ..Default::default()
1188 },
1189 );
1190 push_execution_policy(CapabilityPolicy {
1191 tool_annotations,
1192 ..Default::default()
1193 });
1194
1195 let policy = ToolApprovalPolicy {
1196 write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
1197 ..Default::default()
1198 };
1199 let decision = policy.evaluate(
1200 "write_file",
1201 &serde_json::json!({"path": "/packages/demo/file.txt"}),
1202 );
1203 assert_eq!(decision, ToolApprovalDecision::AutoApproved);
1204
1205 pop_execution_policy();
1206 crate::stdlib::process::set_thread_execution_context(None);
1207 }
1208
1209 #[test]
1210 fn write_path_allowlist_does_not_block_read_only_tools() {
1211 let temp = tempfile::tempdir().unwrap();
1212 std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
1213 std::fs::write(temp.path().join("packages/demo/context.txt"), "ok").unwrap();
1214 crate::stdlib::process::set_thread_execution_context(Some(
1215 crate::orchestration::RunExecutionRecord {
1216 cwd: Some(temp.path().to_string_lossy().into_owned()),
1217 project_root: None,
1218 source_dir: Some(temp.path().to_string_lossy().into_owned()),
1219 env: BTreeMap::new(),
1220 adapter: None,
1221 repo_path: None,
1222 worktree_path: None,
1223 branch: None,
1224 base_ref: None,
1225 cleanup: None,
1226 environment_policy: Default::default(),
1227 grants: Vec::new(),
1228 },
1229 ));
1230
1231 let mut tool_annotations = BTreeMap::new();
1232 tool_annotations.insert(
1233 "read_file".to_string(),
1234 ToolAnnotations {
1235 kind: ToolKind::Read,
1236 arg_schema: ToolArgSchema {
1237 path_params: vec!["path".to_string()],
1238 ..Default::default()
1239 },
1240 ..Default::default()
1241 },
1242 );
1243 push_execution_policy(CapabilityPolicy {
1244 tool_annotations,
1245 ..Default::default()
1246 });
1247
1248 let policy = ToolApprovalPolicy {
1249 write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
1250 ..Default::default()
1251 };
1252 let decision = policy.evaluate(
1253 "read_file",
1254 &serde_json::json!({"path": "/packages/demo/context.txt"}),
1255 );
1256 assert_eq!(decision, ToolApprovalDecision::AutoApproved);
1257
1258 pop_execution_policy();
1259 crate::stdlib::process::set_thread_execution_context(None);
1260 }
1261
1262 #[test]
1263 fn builtin_policy_covers_fs_read_and_list_helpers() {
1264 clear_execution_policy_stacks();
1265 push_execution_policy(CapabilityPolicy {
1266 capabilities: BTreeMap::from([("workspace".to_string(), vec!["exists".to_string()])]),
1267 side_effect_level: Some("read_only".to_string()),
1268 ..CapabilityPolicy::default()
1269 });
1270
1271 for name in [
1272 "read_lines",
1273 "find_text",
1274 "find_evidence",
1275 "walk_dir",
1276 "glob",
1277 "project_context_profile_native",
1278 ] {
1279 assert!(
1280 enforce_current_policy_for_builtin(name, &[]).is_err(),
1281 "{name} should be rejected when the matching workspace capability is absent"
1282 );
1283 }
1284
1285 pop_execution_policy();
1286 }
1287
1288 #[test]
1289 fn move_file_requires_workspace_write_side_effect() {
1290 clear_execution_policy_stacks();
1291 push_execution_policy(CapabilityPolicy {
1292 capabilities: BTreeMap::from([(
1293 "workspace".to_string(),
1294 vec!["write_text".to_string()],
1295 )]),
1296 side_effect_level: Some("read_only".to_string()),
1297 ..CapabilityPolicy::default()
1298 });
1299
1300 let error = enforce_current_policy_for_builtin("move_file", &[]).unwrap_err();
1301 assert!(
1302 error.to_string().contains("workspace write ceiling"),
1303 "unexpected error: {error}"
1304 );
1305
1306 pop_execution_policy();
1307 }
1308
1309 #[test]
1310 fn unix_socket_json_request_requires_network_side_effect() {
1311 clear_execution_policy_stacks();
1312 push_execution_policy(CapabilityPolicy {
1313 side_effect_level: Some("read_only".to_string()),
1314 ..CapabilityPolicy::default()
1315 });
1316
1317 let error =
1318 enforce_current_policy_for_builtin("__net_unix_socket_json_request", &[]).unwrap_err();
1319 assert!(
1320 error.to_string().contains("network.http ceiling"),
1321 "unexpected error: {error}"
1322 );
1323
1324 pop_execution_policy();
1325 }
1326
1327 #[test]
1328 fn files_upload_requires_workspace_read_and_network_side_effect() {
1329 clear_execution_policy_stacks();
1330 push_execution_policy(CapabilityPolicy {
1331 capabilities: BTreeMap::from([
1332 ("workspace".to_string(), vec!["read_text".to_string()]),
1333 ("network".to_string(), vec!["http".to_string()]),
1334 ]),
1335 side_effect_level: Some("read_only".to_string()),
1336 ..CapabilityPolicy::default()
1337 });
1338
1339 let network_error = enforce_current_policy_for_builtin("__files_upload", &[]).unwrap_err();
1340 assert!(
1341 network_error.to_string().contains("network.http ceiling"),
1342 "unexpected error: {network_error}"
1343 );
1344 pop_execution_policy();
1345
1346 push_execution_policy(CapabilityPolicy {
1347 capabilities: BTreeMap::from([
1348 ("workspace".to_string(), vec!["exists".to_string()]),
1349 ("network".to_string(), vec!["http".to_string()]),
1350 ]),
1351 side_effect_level: Some("network".to_string()),
1352 ..CapabilityPolicy::default()
1353 });
1354 let read_error = enforce_current_policy_for_builtin("__files_upload", &[]).unwrap_err();
1355 assert!(
1356 read_error.to_string().contains("workspace.read_text"),
1357 "unexpected error: {read_error}"
1358 );
1359
1360 pop_execution_policy();
1361 }
1362}
1363
1364#[cfg(test)]
1365mod turn_policy_tests {
1366 use super::TurnPolicy;
1367
1368 #[test]
1369 fn default_allows_done_sentinel() {
1370 let policy = TurnPolicy::default();
1371 assert!(policy.allow_done_sentinel);
1372 assert!(!policy.require_action_or_yield);
1373 assert!(policy.max_prose_chars.is_none());
1374 }
1375
1376 #[test]
1377 fn deserializing_partial_dict_preserves_done_sentinel_pathway() {
1378 let policy: TurnPolicy =
1383 serde_json::from_value(serde_json::json!({ "require_action_or_yield": true }))
1384 .expect("deserialize");
1385 assert!(policy.require_action_or_yield);
1386 assert!(policy.allow_done_sentinel);
1387 }
1388
1389 #[test]
1390 fn deserializing_explicit_false_disables_done_sentinel() {
1391 let policy: TurnPolicy = serde_json::from_value(serde_json::json!({
1392 "require_action_or_yield": true,
1393 "allow_done_sentinel": false,
1394 }))
1395 .expect("deserialize");
1396 assert!(policy.require_action_or_yield);
1397 assert!(!policy.allow_done_sentinel);
1398 }
1399}
1400
1401#[cfg(test)]
1402mod visibility_redaction_tests {
1403 use super::*;
1404 use crate::value::VmValue;
1405
1406 fn mock_transcript() -> VmValue {
1407 let messages = vec![
1408 serde_json::json!({"role": "user", "content": "hi"}),
1409 serde_json::json!({"role": "assistant", "content": "hello"}),
1410 serde_json::json!({"role": "tool_result", "content": "internal tool output"}),
1411 ];
1412 crate::llm::helpers::transcript_to_vm_with_events(
1413 Some("test-id".to_string()),
1414 None,
1415 None,
1416 &messages,
1417 Vec::new(),
1418 Vec::new(),
1419 Some("active"),
1420 )
1421 }
1422
1423 fn message_count(transcript: &VmValue) -> usize {
1424 transcript
1425 .as_dict()
1426 .and_then(|d| d.get("messages"))
1427 .and_then(|v| match v {
1428 VmValue::List(list) => Some(list.len()),
1429 _ => None,
1430 })
1431 .unwrap_or(0)
1432 }
1433
1434 #[test]
1435 fn visibility_none_returns_unchanged() {
1436 let t = mock_transcript();
1437 let result = redact_transcript_visibility(&t, None).unwrap();
1438 assert_eq!(message_count(&result), 3);
1439 }
1440
1441 #[test]
1442 fn visibility_public_drops_tool_results() {
1443 let t = mock_transcript();
1444 let result = redact_transcript_visibility(&t, Some("public")).unwrap();
1445 assert_eq!(message_count(&result), 2);
1446 }
1447
1448 #[test]
1449 fn visibility_public_drops_private_content_blocks() {
1450 let t = crate::schema::json_to_vm_value(&serde_json::json!({
1451 "messages": [
1452 {
1453 "role": "assistant",
1454 "visibility": "public",
1455 "text": "visible answer\nsecret chain",
1456 "content": [
1457 {"type": "output_text", "text": "visible answer", "visibility": "public"},
1458 {"type": "reasoning", "text": "secret chain", "visibility": "private"}
1459 ],
1460 "blocks": [
1461 {"type": "output_text", "text": "visible block", "visibility": "public"},
1462 {"type": "tool_call", "text": "internal args", "visibility": "internal"}
1463 ]
1464 }
1465 ],
1466 "events": []
1467 }));
1468
1469 let result = redact_transcript_visibility(&t, Some("public")).unwrap();
1470 let rendered = result.display();
1471 assert!(rendered.contains("visible answer"));
1472 assert!(rendered.contains("visible block"));
1473 assert!(!rendered.contains("secret chain"));
1474 assert!(!rendered.contains("internal args"));
1475 }
1476
1477 #[test]
1478 fn visibility_unknown_string_is_pass_through() {
1479 let t = mock_transcript();
1480 let result = redact_transcript_visibility(&t, Some("internal")).unwrap();
1481 assert_eq!(message_count(&result), 3);
1482 }
1483}