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