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