Skip to main content

harn_vm/orchestration/policy/
mod.rs

1//! Policy types and capability-ceiling enforcement.
2
3mod approval_rules;
4mod effects;
5mod nested_budget;
6mod types;
7
8use crate::value::VmDictExt;
9use std::cell::RefCell;
10use std::collections::BTreeMap;
11use std::thread_local;
12
13use serde::{Deserialize, Serialize};
14
15use crate::runtime_limits::RuntimeLimits;
16use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
17use crate::value::{VmError, VmValue};
18use crate::workspace_path::{classify_workspace_path, WorkspacePathInfo};
19
20pub use crate::tool_annotations::{ToolArgSchema, ToolKind};
21pub use approval_rules::{
22    clear_all_approval_policy_repeat_counts, clear_approval_policy_repeat_counts,
23    next_approval_policy_repeat_count, ApprovalShape, PolicyAction, PolicyEvaluation,
24    PolicyMatchedRule, PolicyRule, PolicyRuleMatch,
25};
26pub use effects::{
27    compute_handoff_effects, effect_kind_label, effect_record_summary, effect_subset_violations,
28    effects_from_metadata, EffectKind, EffectRecord, EffectScope,
29};
30pub use nested_budget::{
31    annotate_nested_execution_options, enter_nested_execution_policy, NestedExecutionGuard,
32    NestedExecutionKind, NESTED_KIND_OPTION_KEY, NESTED_LABEL_OPTION_KEY,
33};
34pub use types::{
35    enforce_tool_arg_constraints, AutoCompactPolicy, BranchSemantics, CapabilityPolicy,
36    ContextPolicy, EqIgnored, EscalationPolicy, JoinPolicy, MapPolicy, ModelPolicy,
37    NativeToolFallbackPolicy, ProcessSandboxPolicy, ProcessSandboxPreset, ReducePolicy,
38    RetryPolicy, SandboxProfile, StageContract, ToolArgConstraint, TurnPolicy,
39};
40
41thread_local! {
42    static EXECUTION_POLICY_STACK: RefCell<Vec<CapabilityPolicy>> = const { RefCell::new(Vec::new()) };
43    static EXECUTION_APPROVAL_POLICY_STACK: RefCell<Vec<ToolApprovalPolicy>> = const { RefCell::new(Vec::new()) };
44    static TRUSTED_BRIDGE_CALL_DEPTH: RefCell<usize> = const { RefCell::new(0) };
45}
46
47pub fn push_execution_policy(policy: CapabilityPolicy) {
48    EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
49}
50
51pub fn pop_execution_policy() {
52    EXECUTION_POLICY_STACK.with(|stack| {
53        stack.borrow_mut().pop();
54    });
55}
56
57pub fn clear_execution_policy_stacks() {
58    EXECUTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
59    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
60    TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow_mut() = 0);
61}
62
63pub fn current_execution_policy() -> Option<CapabilityPolicy> {
64    EXECUTION_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
65}
66
67pub fn push_approval_policy(policy: ToolApprovalPolicy) {
68    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
69}
70
71pub fn pop_approval_policy() {
72    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| {
73        stack.borrow_mut().pop();
74    });
75}
76
77pub fn current_approval_policy() -> Option<ToolApprovalPolicy> {
78    EXECUTION_APPROVAL_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
79}
80
81pub fn current_tool_annotations(tool: &str) -> Option<ToolAnnotations> {
82    current_execution_policy().and_then(|policy| policy.tool_annotations.get(tool).cloned())
83}
84
85/// The explicit tool allowlist the active execution policy advertises, for
86/// building actionable denial feedback that names what the model *can* call.
87///
88/// Prefers `policy.tools` (the explicit ceiling — what the eval lane sets);
89/// falls back to the annotation registry keys when no explicit list is present.
90/// Returns an empty `Vec` when no policy is active or the surface is unbounded
91/// (allow-all), in which case callers keep their generic guidance.
92pub fn current_allowed_tool_names() -> Vec<String> {
93    let Some(policy) = current_execution_policy() else {
94        return Vec::new();
95    };
96    if !policy.tools.is_empty() {
97        return policy.tools;
98    }
99    policy.tool_annotations.keys().cloned().collect()
100}
101
102pub(super) fn tool_kind_participates_in_write_allowlist(tool_name: &str) -> bool {
103    current_tool_annotations(tool_name)
104        .map(|annotations| !annotations.kind.is_read_only())
105        .unwrap_or(true)
106}
107
108pub struct TrustedBridgeCallGuard;
109
110pub fn allow_trusted_bridge_calls() -> TrustedBridgeCallGuard {
111    TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
112        *depth.borrow_mut() += 1;
113    });
114    TrustedBridgeCallGuard
115}
116
117impl Drop for TrustedBridgeCallGuard {
118    fn drop(&mut self) {
119        TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| {
120            let mut depth = depth.borrow_mut();
121            *depth = depth.saturating_sub(1);
122        });
123    }
124}
125
126fn policy_allows_tool(policy: &CapabilityPolicy, tool: &str) -> bool {
127    policy.tools.is_empty() || policy.tools.iter().any(|allowed| allowed == tool)
128}
129
130fn policy_grants_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
131    policy
132        .capabilities
133        .get(capability)
134        .is_some_and(|ops| ops.is_empty() || ops.iter().any(|allowed| allowed == op))
135}
136
137fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, op: &str) -> bool {
138    if policy.capabilities.is_empty() {
139        // Empty capability map = allow-all (e.g. the root agent policy).
140        return true;
141    }
142    if policy_grants_capability(policy, capability, op) {
143        return true;
144    }
145    // Capability subsumption: a stronger read grant implies the weaker
146    // observations it already exposes. An existence/metadata probe
147    // (`workspace.exists`, used by `file_exists`/`stat`) reveals strictly less
148    // than reading file contents (`workspace.read_text`) or listing a directory
149    // (`workspace.list`) — both of which already disclose whether a path
150    // exists. A policy that grants read/list but withholds the existence probe
151    // is incoherent, and silently wedges any tool that stats a path before
152    // reading it (look, read_file, edit/scaffold preflight). Narrowed worker
153    // policies derived from tool annotations hit this constantly because no
154    // annotation declares `workspace.exists`. Encode the lattice once here so
155    // every narrowed policy benefits, not one dispatch surface at a time.
156    if capability == "workspace" && op == "exists" {
157        return policy_grants_capability(policy, "workspace", "read_text")
158            || policy_grants_capability(policy, "workspace", "list");
159    }
160    false
161}
162
163fn policy_allows_side_effect(policy: &CapabilityPolicy, requested: &str) -> bool {
164    fn rank(v: &str) -> usize {
165        match v {
166            "none" => 0,
167            "read_only" => 1,
168            "workspace_write" => 2,
169            "process_exec" => 3,
170            "network" => 4,
171            _ => 5,
172        }
173    }
174    policy
175        .side_effect_level
176        .as_ref()
177        .map(|allowed| rank(allowed) >= rank(requested))
178        .unwrap_or(true)
179}
180
181pub(super) fn reject_policy(reason: String) -> Result<(), VmError> {
182    Err(VmError::CategorizedError {
183        message: reason,
184        category: crate::value::ErrorCategory::ToolRejected,
185    })
186}
187
188/// Structured refusal produced by the agent-tool capability gates
189/// (`enforce_current_policy_for_tool`, `enforce_tool_arg_constraints`).
190/// Records the gate identity and the exceeded capability so the dispatch
191/// boundary can build a full [`crate::agent_events::ToolDenial`] for the
192/// model and host. `From<PolicyDenial> for VmError` keeps the legacy
193/// `?`-using callers — which only need the categorized error — unchanged.
194#[derive(Clone, Debug, PartialEq, Eq)]
195pub struct PolicyDenial {
196    pub gate: crate::agent_events::DenialGate,
197    pub capability: Option<String>,
198    pub reason: String,
199}
200
201impl From<PolicyDenial> for VmError {
202    fn from(denial: PolicyDenial) -> Self {
203        VmError::CategorizedError {
204            message: denial.reason,
205            category: crate::value::ErrorCategory::ToolRejected,
206        }
207    }
208}
209
210pub(super) fn reject_tool(
211    gate: crate::agent_events::DenialGate,
212    capability: Option<String>,
213    reason: String,
214) -> Result<(), PolicyDenial> {
215    Err(PolicyDenial {
216        gate,
217        capability,
218        reason,
219    })
220}
221
222/// Mutation classification for a tool, derived from the pipeline's
223/// declared `ToolKind`. Used in telemetry and pre/post-bridge payloads
224/// while those methods still exist. Returns `"other"` for unannotated
225/// tools (fail-safe; unknown tools don't auto-classify).
226pub fn current_tool_mutation_classification(tool_name: &str) -> String {
227    current_tool_annotations(tool_name)
228        .map(|annotations| annotations.kind.mutation_class().to_string())
229        .unwrap_or_else(|| "other".to_string())
230}
231
232/// Workspace paths declared by this tool call, read from the tool's
233/// annotated `arg_schema.path_params`. Unannotated tools declare no
234/// paths — the VM no longer guesses by common argument names.
235pub fn current_tool_declared_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
236    current_tool_declared_path_entries(tool_name, args)
237        .into_iter()
238        .map(|entry| entry.display_path().to_string())
239        .collect()
240}
241
242/// Rich workspace-path descriptors declared by this tool call. Each
243/// entry preserves the original input while also projecting the path
244/// into workspace-relative and host-absolute forms when that mapping is
245/// known.
246pub fn current_tool_declared_path_entries(
247    tool_name: &str,
248    args: &serde_json::Value,
249) -> Vec<WorkspacePathInfo> {
250    let Some(map) = args.as_object() else {
251        return Vec::new();
252    };
253    let Some(annotations) = current_tool_annotations(tool_name) else {
254        return Vec::new();
255    };
256    let workspace_root = crate::stdlib::process::execution_root_path();
257    let mut entries = Vec::new();
258    for key in &annotations.arg_schema.path_params {
259        if let Some(value) = map.get(key) {
260            match value {
261                serde_json::Value::String(path) if !path.is_empty() => {
262                    entries.push(classify_workspace_path(path, Some(&workspace_root)));
263                }
264                serde_json::Value::Array(items) => {
265                    for item in items.iter().filter_map(|item| item.as_str()) {
266                        if !item.is_empty() {
267                            entries.push(classify_workspace_path(item, Some(&workspace_root)));
268                        }
269                    }
270                }
271                _ => {}
272            }
273        }
274    }
275    entries.sort_by(|a, b| a.display_path().cmp(b.display_path()));
276    entries.dedup_by(|left, right| left.policy_candidates() == right.policy_candidates());
277    entries
278}
279
280pub fn enforce_current_policy_for_builtin(name: &str, args: &[VmValue]) -> Result<(), VmError> {
281    let Some(policy) = current_execution_policy() else {
282        return Ok(());
283    };
284    match name {
285        "find_text"
286            if !policy_allows_capability(&policy, "workspace", "read_text")
287                || !policy_allows_capability(&policy, "workspace", "list") =>
288        {
289            return reject_policy(
290                "builtin 'find_text' exceeds workspace.read_text/workspace.list ceiling"
291                    .to_string(),
292            );
293        }
294        "read_file"
295        | "read_file_result"
296        | "read_file_bytes"
297        | "render"
298        | "render_prompt"
299        | "render_with_provenance"
300        | "read_lines"
301            if !policy_allows_capability(&policy, "workspace", "read_text") =>
302        {
303            return reject_policy(format!(
304                "builtin '{name}' exceeds workspace.read_text ceiling"
305            ));
306        }
307        "list_dir" | "walk_dir" | "glob"
308            if !policy_allows_capability(&policy, "workspace", "list") =>
309        {
310            return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
311        }
312        "file_exists" | "stat" if !policy_allows_capability(&policy, "workspace", "exists") => {
313            return reject_policy(format!("builtin '{name}' exceeds workspace.exists ceiling"));
314        }
315        "write_file" | "write_file_bytes" | "append_file" | "mkdir" | "copy_file" | "move_file"
316            if !policy_allows_capability(&policy, "workspace", "write_text")
317                || !policy_allows_side_effect(&policy, "workspace_write") =>
318        {
319            return reject_policy(format!("builtin '{name}' exceeds workspace write ceiling"));
320        }
321        "delete_file"
322            if !policy_allows_capability(&policy, "workspace", "delete")
323                || !policy_allows_side_effect(&policy, "workspace_write") =>
324        {
325            return reject_policy(
326                "builtin 'delete_file' exceeds workspace.delete ceiling".to_string(),
327            );
328        }
329        "apply_edit"
330            if !policy_allows_capability(&policy, "workspace", "apply_edit")
331                || !policy_allows_side_effect(&policy, "workspace_write") =>
332        {
333            return reject_policy(
334                "builtin 'apply_edit' exceeds workspace.apply_edit ceiling".to_string(),
335            );
336        }
337        "exec"
338        | "exec_at"
339        | "shell"
340        | "shell_at"
341        | "git.repo.discover"
342        | "git.worktree.create"
343        | "git.worktree.remove"
344        | "git.fetch"
345        | "git.rebase"
346        | "git.status"
347        | "git.conflicts"
348        | "git.push"
349        | "git.diff"
350        | "git.merge_base"
351            if !policy_allows_capability(&policy, "process", "exec")
352                || !policy_allows_side_effect(&policy, "process_exec") =>
353        {
354            return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
355        }
356        "http_get"
357        | "http_post"
358        | "http_put"
359        | "http_patch"
360        | "http_delete"
361        | "http_download"
362        | "http_request"
363        | "unix_socket_json_request"
364        | "__net_unix_socket_json_request"
365            if !policy_allows_side_effect(&policy, "network") =>
366        {
367            return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
368        }
369        "__files_upload"
370            if !policy_allows_capability(&policy, "workspace", "read_text")
371                || !policy_allows_side_effect(&policy, "network") =>
372        {
373            return reject_policy(
374                "builtin '__files_upload' exceeds workspace.read_text/network ceiling".to_string(),
375            );
376        }
377        "http_session_request"
378        | "http_stream_open"
379        | "http_stream_read"
380        | "http_stream_close"
381        | "http_stream_info"
382        | "sse_connect"
383        | "sse_receive"
384        | "websocket_accept"
385        | "websocket_connect"
386        | "websocket_route"
387        | "websocket_send"
388        | "websocket_receive"
389        | "websocket_server"
390            if !policy_allows_side_effect(&policy, "network") =>
391        {
392            return reject_policy(format!("builtin '{name}' exceeds network ceiling"));
393        }
394        "llm_call" | "llm_call_safe" | "llm_completion" | "llm_stream" | "llm_stream_call"
395        | "llm_healthcheck" | "agent_loop"
396            if !policy_allows_capability(&policy, "llm", "call") =>
397        {
398            return reject_policy(format!("builtin '{name}' exceeds llm.call ceiling"));
399        }
400        "connector_call"
401            if !policy_allows_capability(&policy, "connector", "call")
402                || !policy_allows_side_effect(&policy, "network") =>
403        {
404            return reject_policy(
405                "builtin 'connector_call' exceeds connector.call/network ceiling".to_string(),
406            );
407        }
408        "secret_get" if !policy_allows_capability(&policy, "connector", "secret_get") => {
409            return reject_policy(
410                "builtin 'secret_get' exceeds connector.secret_get ceiling".to_string(),
411            );
412        }
413        "event_log_emit" if !policy_allows_capability(&policy, "connector", "event_log_emit") => {
414            return reject_policy(
415                "builtin 'event_log_emit' exceeds connector.event_log_emit ceiling".to_string(),
416            );
417        }
418        "metrics_inc" if !policy_allows_capability(&policy, "connector", "metrics_inc") => {
419            return reject_policy(
420                "builtin 'metrics_inc' exceeds connector.metrics_inc ceiling".to_string(),
421            );
422        }
423        "project_fingerprint"
424        | "project_context_profile_native"
425        | "project_scan_native"
426        | "project_scan_tree_native"
427        | "project_walk_tree_native"
428        | "project_catalog_native"
429            if !policy_allows_capability(&policy, "workspace", "list")
430                || !policy_allows_side_effect(&policy, "read_only") =>
431        {
432            return reject_policy(format!("builtin '{name}' exceeds workspace.list ceiling"));
433        }
434        "__agent_state_init"
435        | "__agent_state_resume"
436        | "__agent_state_write"
437        | "__agent_state_read"
438        | "__agent_state_list"
439        | "__agent_state_delete"
440        | "__agent_state_handoff"
441            if !policy_allows_capability(&policy, "agent_state", "access") =>
442        {
443            return reject_policy(format!(
444                "builtin '{name}' exceeds agent_state.access ceiling"
445            ));
446        }
447        "vision_ocr"
448            if !policy_allows_capability(&policy, "vision", "ocr")
449                || !policy_allows_side_effect(&policy, "process_exec") =>
450        {
451            return reject_policy(format!(
452                "builtin '{name}' exceeds vision.ocr/process ceiling"
453            ));
454        }
455        "mcp_connect"
456        | "mcp_ensure_active"
457        | "mcp_call"
458        | "mcp_list_tools"
459        | "mcp_list_resources"
460        | "mcp_list_resource_templates"
461        | "mcp_read_resource"
462        | "mcp_list_prompts"
463        | "mcp_get_prompt"
464        | "mcp_server_info"
465        | "mcp_disconnect"
466            if !policy_allows_capability(&policy, "process", "exec")
467                || !policy_allows_side_effect(&policy, "process_exec") =>
468        {
469            return reject_policy(format!("builtin '{name}' exceeds process.exec ceiling"));
470        }
471        "host_call" => {
472            let name = args.first().map(|v| v.display()).unwrap_or_default();
473            let Some((capability, op)) = name.split_once('.') else {
474                return reject_policy(format!(
475                    "host_call '{name}' must use capability.operation naming"
476                ));
477            };
478            if !policy_allows_capability(&policy, capability, op) {
479                return reject_policy(format!(
480                    "host_call {capability}.{op} exceeds capability ceiling"
481                ));
482            }
483            let requested_side_effect = match (capability, op) {
484                ("workspace", "write_text" | "apply_edit" | "delete") => "workspace_write",
485                ("process", "exec") => "process_exec",
486                _ => "read_only",
487            };
488            if !policy_allows_side_effect(&policy, requested_side_effect) {
489                return reject_policy(format!(
490                    "host_call {capability}.{op} exceeds side-effect ceiling"
491                ));
492            }
493        }
494        "host_tool_list" | "host_tool_call"
495            if !policy_allows_capability(&policy, "host", "tool_call") =>
496        {
497            return reject_policy(format!("builtin '{name}' exceeds host.tool_call ceiling"));
498        }
499        _ => {}
500    }
501    Ok(())
502}
503
504pub fn enforce_current_policy_for_bridge_builtin(name: &str) -> Result<(), VmError> {
505    let trusted = TRUSTED_BRIDGE_CALL_DEPTH.with(|depth| *depth.borrow() > 0);
506    if trusted {
507        return Ok(());
508    }
509    if current_execution_policy().is_some() {
510        return reject_policy(format!(
511            "bridged builtin '{name}' exceeds execution policy; declare an explicit capability/tool surface instead"
512        ));
513    }
514    Ok(())
515}
516
517pub fn enforce_current_policy_for_tool(tool_name: &str) -> Result<(), PolicyDenial> {
518    use crate::agent_events::DenialGate;
519    let Some(policy) = current_execution_policy() else {
520        return Ok(());
521    };
522    if !policy_allows_tool(&policy, tool_name) {
523        return reject_tool(
524            DenialGate::ToolCeiling,
525            None,
526            format!("tool '{tool_name}' exceeds tool ceiling"),
527        );
528    }
529    if let Some(annotations) = policy.tool_annotations.get(tool_name) {
530        for (capability, ops) in &annotations.capabilities {
531            for op in ops {
532                if !policy_allows_capability(&policy, capability, op) {
533                    return reject_tool(
534                        DenialGate::CapabilityCeiling,
535                        Some(format!("{capability}.{op}")),
536                        format!("tool '{tool_name}' exceeds capability ceiling: {capability}.{op}"),
537                    );
538                }
539            }
540        }
541        let requested_level = annotations.side_effect_level;
542        if requested_level != SideEffectLevel::None
543            && !policy_allows_side_effect(&policy, requested_level.as_str())
544        {
545            return reject_tool(
546                DenialGate::SideEffectCeiling,
547                None,
548                format!(
549                    "tool '{tool_name}' exceeds side-effect ceiling: {}",
550                    requested_level.as_str()
551                ),
552            );
553        }
554    }
555    Ok(())
556}
557
558// ── Output visibility redaction ─────────────────────────────────────
559//
560// Transcript lifecycle (reset, fork, trim, compact) now lives on
561// `crate::agent_sessions` as explicit imperative builtins. All that
562// remains here is the per-call visibility filter, which is
563// output-shaping (not lifecycle).
564
565/// Filter a transcript dict down to the caller-visible subset, based
566/// on the `output_visibility` node option. `None` or any unknown
567/// visibility returns the transcript unchanged — callers are expected
568/// to validate the string against a known set upstream.
569pub fn redact_transcript_visibility(
570    transcript: &VmValue,
571    visibility: Option<&str>,
572) -> Option<VmValue> {
573    let Some(visibility) = visibility else {
574        return Some(transcript.clone());
575    };
576    if visibility != "public" && visibility != "public_only" {
577        return Some(transcript.clone());
578    }
579    let dict = transcript.as_dict()?;
580    let public_messages = match dict.get("messages") {
581        Some(VmValue::List(list)) => list
582            .iter()
583            .filter_map(redact_public_message)
584            .collect::<Vec<_>>(),
585        _ => Vec::new(),
586    };
587    let public_events = match dict.get("events") {
588        Some(VmValue::List(list)) => list
589            .iter()
590            .filter(|event| {
591                event
592                    .as_dict()
593                    .and_then(|d| d.get("visibility"))
594                    .map(|v| v.display())
595                    .map(|value| value == "public")
596                    .unwrap_or(true)
597            })
598            .cloned()
599            .collect::<Vec<_>>(),
600        _ => Vec::new(),
601    };
602    let mut redacted = dict.clone();
603    redacted.insert(
604        crate::value::intern_key("messages"),
605        VmValue::List(std::sync::Arc::new(public_messages)),
606    );
607    redacted.insert(
608        crate::value::intern_key("events"),
609        VmValue::List(std::sync::Arc::new(public_events)),
610    );
611    Some(VmValue::dict(redacted))
612}
613
614fn redact_public_message(message: &VmValue) -> Option<VmValue> {
615    let Some(dict) = message.as_dict() else {
616        return Some(message.clone());
617    };
618    if dict.get("role").map(|value| value.display()).as_deref() == Some("tool_result") {
619        return None;
620    }
621    if dict
622        .get("visibility")
623        .map(|value| value.display())
624        .is_some_and(|visibility| visibility != "public")
625    {
626        return None;
627    }
628
629    let mut redacted = dict.clone();
630    let mut saw_structured_blocks = false;
631    let mut public_text = Vec::new();
632    for key in ["content", "blocks"] {
633        if let Some(VmValue::List(blocks)) = dict.get(key) {
634            saw_structured_blocks = true;
635            let public_blocks = blocks
636                .iter()
637                .filter_map(redact_public_block)
638                .collect::<Vec<_>>();
639            if key == "blocks" || public_text.is_empty() {
640                public_text = text_fragments_from_blocks(&public_blocks);
641            }
642            redacted.insert(
643                crate::value::intern_key(key),
644                VmValue::List(std::sync::Arc::new(public_blocks)),
645            );
646        }
647    }
648    if saw_structured_blocks {
649        if public_text.is_empty() {
650            redacted.remove("text");
651        } else {
652            redacted.put_str("text", public_text.join("\n"));
653        }
654    }
655    Some(VmValue::dict(redacted))
656}
657
658fn redact_public_block(block: &VmValue) -> Option<VmValue> {
659    let Some(dict) = block.as_dict() else {
660        return Some(block.clone());
661    };
662    if dict
663        .get("visibility")
664        .map(|value| value.display())
665        .is_some_and(|visibility| visibility != "public")
666    {
667        return None;
668    }
669    Some(block.clone())
670}
671
672fn text_fragments_from_blocks(blocks: &[VmValue]) -> Vec<String> {
673    blocks
674        .iter()
675        .filter_map(|block| block.as_dict())
676        .filter_map(|dict| dict.get("text"))
677        .filter_map(|text| match text {
678            VmValue::String(value) if !value.is_empty() => Some(value.to_string()),
679            _ => None,
680        })
681        .collect()
682}
683
684pub fn builtin_ceiling() -> CapabilityPolicy {
685    CapabilityPolicy {
686        // `capabilities` is intentionally empty: the host capability manifest
687        // is the sole authority, and an allowlist here would silently block
688        // any capability the host adds later.
689        tools: Vec::new(),
690        capabilities: BTreeMap::new(),
691        workspace_roots: Vec::new(),
692        read_only_roots: Vec::new(),
693        side_effect_level: Some("network".to_string()),
694        recursion_limit: Some(RuntimeLimits::DEFAULT.max_nested_execution_depth),
695        tool_arg_constraints: Vec::new(),
696        tool_annotations: BTreeMap::new(),
697        sandbox_profile: SandboxProfile::Worktree,
698        process_sandbox: Default::default(),
699    }
700}
701
702/// Declarative policy for tool approval gating. Allows pipelines to
703/// specify which tools are auto-approved, auto-denied, or require
704/// host confirmation, plus write-path allowlists.
705#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(default)]
707pub struct ToolApprovalPolicy {
708    /// Ordered allow/ask/deny rules over tool metadata, path, command,
709    /// URL, MCP, agent/persona/mode, and repeat-count dimensions.
710    #[serde(default)]
711    pub rules: Vec<PolicyRule>,
712    /// Glob patterns for tools that should be auto-approved.
713    #[serde(default)]
714    pub auto_approve: Vec<String>,
715    /// Glob patterns for tools that should always be denied.
716    #[serde(default)]
717    pub auto_deny: Vec<String>,
718    /// Glob patterns for tools that require host confirmation.
719    #[serde(default)]
720    pub require_approval: Vec<String>,
721    /// Glob patterns for writable paths.
722    #[serde(default)]
723    pub write_path_allowlist: Vec<String>,
724    /// Explicit opt-out for the deny-by-default sensitive-path guard.
725    #[serde(default)]
726    pub allow_sensitive_paths: bool,
727    /// Additional or replacement sensitive path globs. Empty uses the
728    /// runtime defaults such as `.env`, private keys, and credential files.
729    #[serde(default)]
730    pub sensitive_path_patterns: Vec<String>,
731    /// Explicit opt-out for the external-path guard on declared path args.
732    #[serde(default)]
733    pub allow_external_paths: bool,
734    /// Host-absolute roots allowed when `allow_external_paths` is false.
735    #[serde(default)]
736    pub external_roots: Vec<String>,
737    /// Optional repeated-call threshold for the same `(session, tool, args)`.
738    #[serde(default, alias = "repeated_call_limit")]
739    pub repeat_limit: Option<u64>,
740    /// Action for `repeat_limit`; defaults to `ask`.
741    #[serde(default, alias = "repeated_call_action")]
742    pub repeat_action: Option<PolicyAction>,
743}
744
745/// Result of evaluating a tool call against a ToolApprovalPolicy.
746#[derive(Debug, Clone, PartialEq, Eq)]
747pub enum ToolApprovalDecision {
748    /// Tool is auto-approved by policy.
749    AutoApproved,
750    /// Tool is auto-denied by policy.
751    AutoDenied { reason: String },
752    /// Tool requires explicit host approval; the caller already owns the
753    /// tool name and args and forwards them to the host bridge.
754    RequiresHostApproval,
755}
756
757impl ToolApprovalPolicy {
758    pub fn evaluate_detailed(&self, tool_name: &str, args: &serde_json::Value) -> PolicyEvaluation {
759        approval_rules::evaluate_tool_approval_policy(self, tool_name, args, None)
760    }
761
762    pub fn evaluate_detailed_with_repeat(
763        &self,
764        tool_name: &str,
765        args: &serde_json::Value,
766        repeat_count: u64,
767    ) -> PolicyEvaluation {
768        approval_rules::evaluate_tool_approval_policy(self, tool_name, args, Some(repeat_count))
769    }
770
771    /// Evaluate whether a tool call should be approved, denied, or needs
772    /// host confirmation.
773    pub fn evaluate(&self, tool_name: &str, args: &serde_json::Value) -> ToolApprovalDecision {
774        let decision = self.evaluate_detailed(tool_name, args);
775        if decision.is_deny() {
776            return ToolApprovalDecision::AutoDenied {
777                reason: decision.reason,
778            };
779        }
780        if decision.is_ask() {
781            return ToolApprovalDecision::RequiresHostApproval;
782        }
783        ToolApprovalDecision::AutoApproved
784    }
785
786    /// Merge two approval policies, taking the most restrictive combination.
787    /// - auto_approve: only tools approved by BOTH policies stay approved
788    ///   (if either policy has no patterns, the other's patterns are used)
789    /// - auto_deny / require_approval: union (either policy can deny/gate)
790    /// - write_path_allowlist: intersection (both must allow the path)
791    pub fn intersect(&self, other: &ToolApprovalPolicy) -> ToolApprovalPolicy {
792        let auto_approve = if self.auto_approve.is_empty() {
793            other.auto_approve.clone()
794        } else if other.auto_approve.is_empty() {
795            self.auto_approve.clone()
796        } else {
797            self.auto_approve
798                .iter()
799                .filter(|p| other.auto_approve.contains(p))
800                .cloned()
801                .collect()
802        };
803        let mut auto_deny = self.auto_deny.clone();
804        auto_deny.extend(other.auto_deny.iter().cloned());
805        let mut require_approval = self.require_approval.clone();
806        require_approval.extend(other.require_approval.iter().cloned());
807        let write_path_allowlist = if self.write_path_allowlist.is_empty() {
808            other.write_path_allowlist.clone()
809        } else if other.write_path_allowlist.is_empty() {
810            self.write_path_allowlist.clone()
811        } else {
812            self.write_path_allowlist
813                .iter()
814                .filter(|p| other.write_path_allowlist.contains(p))
815                .cloned()
816                .collect()
817        };
818        let mut rules = self.rules.clone();
819        rules.extend(other.rules.iter().cloned());
820        let mut sensitive_path_patterns = self.sensitive_path_patterns.clone();
821        sensitive_path_patterns.extend(other.sensitive_path_patterns.iter().cloned());
822        sensitive_path_patterns.sort();
823        sensitive_path_patterns.dedup();
824        let external_roots = if self.external_roots.is_empty() {
825            other.external_roots.clone()
826        } else if other.external_roots.is_empty() {
827            self.external_roots.clone()
828        } else {
829            self.external_roots
830                .iter()
831                .filter(|root| other.external_roots.contains(root))
832                .cloned()
833                .collect()
834        };
835        ToolApprovalPolicy {
836            rules,
837            auto_approve,
838            auto_deny,
839            require_approval,
840            write_path_allowlist,
841            allow_sensitive_paths: self.allow_sensitive_paths && other.allow_sensitive_paths,
842            sensitive_path_patterns,
843            allow_external_paths: self.allow_external_paths && other.allow_external_paths,
844            external_roots,
845            repeat_limit: match (self.repeat_limit, other.repeat_limit) {
846                (Some(left), Some(right)) => Some(left.min(right)),
847                (Some(left), None) => Some(left),
848                (None, Some(right)) => Some(right),
849                (None, None) => None,
850            },
851            repeat_action: match (self.repeat_action, other.repeat_action) {
852                (Some(PolicyAction::Deny), _) | (_, Some(PolicyAction::Deny)) => {
853                    Some(PolicyAction::Deny)
854                }
855                (Some(PolicyAction::Ask), _) | (_, Some(PolicyAction::Ask)) => {
856                    Some(PolicyAction::Ask)
857                }
858                (Some(PolicyAction::Allow), Some(PolicyAction::Allow)) => Some(PolicyAction::Allow),
859                (Some(action), None) | (None, Some(action)) => Some(action),
860                (None, None) => None,
861            },
862        }
863    }
864}
865
866#[cfg(test)]
867mod approval_policy_tests {
868    use super::*;
869    use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
870    use crate::tool_annotations::{ToolAnnotations, ToolArgSchema, ToolKind};
871
872    fn workspace_caps(ops: &[&str]) -> CapabilityPolicy {
873        CapabilityPolicy {
874            capabilities: std::collections::BTreeMap::from([(
875                "workspace".to_string(),
876                ops.iter().map(|s| s.to_string()).collect(),
877            )]),
878            ..Default::default()
879        }
880    }
881
882    #[test]
883    fn read_text_subsumes_exists_probe() {
884        // A narrowed worker policy that grants read_text/list (the shape derived
885        // from look/edit/scaffold tool annotations) but never declares the
886        // weaker `workspace.exists` op must still permit `file_exists`/`stat`:
887        // existence is strictly less information than reading the file. Without
888        // subsumption this silently wedged every parallel sub-agent (look denied
889        // -> zero progress -> zero edits).
890        push_execution_policy(workspace_caps(&[
891            "read_text",
892            "list",
893            "write_text",
894            "apply_edit",
895        ]));
896        assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_ok());
897        assert!(enforce_current_policy_for_builtin("stat", &[]).is_ok());
898        pop_execution_policy();
899    }
900
901    #[test]
902    fn list_alone_subsumes_exists_probe() {
903        // Listing a directory already reveals which entries exist.
904        push_execution_policy(workspace_caps(&["list"]));
905        assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_ok());
906        pop_execution_policy();
907    }
908
909    #[test]
910    fn exists_probe_rejected_without_any_read_grant() {
911        // A write-only grant exposes no read surface, so the existence probe is
912        // genuinely above the ceiling and must still be rejected.
913        push_execution_policy(workspace_caps(&["write_text", "apply_edit"]));
914        assert!(enforce_current_policy_for_builtin("file_exists", &[]).is_err());
915        pop_execution_policy();
916    }
917
918    #[test]
919    fn auto_deny_takes_precedence_over_auto_approve() {
920        let policy = ToolApprovalPolicy {
921            auto_approve: vec!["*".to_string()],
922            auto_deny: vec!["dangerous_*".to_string()],
923            ..Default::default()
924        };
925        assert_eq!(
926            policy.evaluate("dangerous_rm", &serde_json::json!({})),
927            ToolApprovalDecision::AutoDenied {
928                reason: "tool 'dangerous_rm' matches deny pattern 'dangerous_*'".to_string()
929            }
930        );
931    }
932
933    #[test]
934    fn auto_approve_matches_glob() {
935        let policy = ToolApprovalPolicy {
936            auto_approve: vec!["read*".to_string(), "search*".to_string()],
937            ..Default::default()
938        };
939        assert_eq!(
940            policy.evaluate("read_file", &serde_json::json!({})),
941            ToolApprovalDecision::AutoApproved
942        );
943        assert_eq!(
944            policy.evaluate("search", &serde_json::json!({})),
945            ToolApprovalDecision::AutoApproved
946        );
947    }
948
949    #[test]
950    fn require_approval_emits_decision() {
951        let policy = ToolApprovalPolicy {
952            require_approval: vec!["edit*".to_string()],
953            ..Default::default()
954        };
955        let decision = policy.evaluate("edit_file", &serde_json::json!({"path": "foo.rs"}));
956        assert!(matches!(
957            decision,
958            ToolApprovalDecision::RequiresHostApproval
959        ));
960    }
961
962    #[test]
963    fn unmatched_tool_defaults_to_approved() {
964        let policy = ToolApprovalPolicy {
965            auto_approve: vec!["read*".to_string()],
966            require_approval: vec!["edit*".to_string()],
967            ..Default::default()
968        };
969        assert_eq!(
970            policy.evaluate("unknown_tool", &serde_json::json!({})),
971            ToolApprovalDecision::AutoApproved
972        );
973    }
974
975    #[test]
976    fn intersect_merges_deny_lists() {
977        let a = ToolApprovalPolicy {
978            auto_deny: vec!["rm*".to_string()],
979            ..Default::default()
980        };
981        let b = ToolApprovalPolicy {
982            auto_deny: vec!["drop*".to_string()],
983            ..Default::default()
984        };
985        let merged = a.intersect(&b);
986        assert_eq!(merged.auto_deny.len(), 2);
987    }
988
989    #[test]
990    fn intersect_restricts_auto_approve_to_common_patterns() {
991        let a = ToolApprovalPolicy {
992            auto_approve: vec!["read*".to_string(), "search*".to_string()],
993            ..Default::default()
994        };
995        let b = ToolApprovalPolicy {
996            auto_approve: vec!["read*".to_string(), "write*".to_string()],
997            ..Default::default()
998        };
999        let merged = a.intersect(&b);
1000        assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
1001    }
1002
1003    #[test]
1004    fn intersect_defers_auto_approve_when_one_side_empty() {
1005        let a = ToolApprovalPolicy {
1006            auto_approve: vec!["read*".to_string()],
1007            ..Default::default()
1008        };
1009        let b = ToolApprovalPolicy::default();
1010        let merged = a.intersect(&b);
1011        assert_eq!(merged.auto_approve, vec!["read*".to_string()]);
1012    }
1013
1014    #[test]
1015    fn write_path_allowlist_matches_recovered_workspace_relative_path() {
1016        let temp = tempfile::tempdir().unwrap();
1017        std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
1018        std::fs::write(temp.path().join("packages/demo/file.txt"), "ok").unwrap();
1019        crate::stdlib::process::set_thread_execution_context(Some(
1020            crate::orchestration::RunExecutionRecord {
1021                cwd: Some(temp.path().to_string_lossy().into_owned()),
1022                source_dir: Some(temp.path().to_string_lossy().into_owned()),
1023                env: BTreeMap::new(),
1024                adapter: None,
1025                repo_path: None,
1026                worktree_path: None,
1027                branch: None,
1028                base_ref: None,
1029                cleanup: None,
1030            },
1031        ));
1032
1033        let mut tool_annotations = BTreeMap::new();
1034        tool_annotations.insert(
1035            "write_file".to_string(),
1036            ToolAnnotations {
1037                kind: ToolKind::Edit,
1038                arg_schema: ToolArgSchema {
1039                    path_params: vec!["path".to_string()],
1040                    ..Default::default()
1041                },
1042                ..Default::default()
1043            },
1044        );
1045        push_execution_policy(CapabilityPolicy {
1046            tool_annotations,
1047            ..Default::default()
1048        });
1049
1050        let policy = ToolApprovalPolicy {
1051            write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
1052            ..Default::default()
1053        };
1054        let decision = policy.evaluate(
1055            "write_file",
1056            &serde_json::json!({"path": "/packages/demo/file.txt"}),
1057        );
1058        assert_eq!(decision, ToolApprovalDecision::AutoApproved);
1059
1060        pop_execution_policy();
1061        crate::stdlib::process::set_thread_execution_context(None);
1062    }
1063
1064    #[test]
1065    fn write_path_allowlist_does_not_block_read_only_tools() {
1066        let temp = tempfile::tempdir().unwrap();
1067        std::fs::create_dir_all(temp.path().join("packages/demo")).unwrap();
1068        std::fs::write(temp.path().join("packages/demo/context.txt"), "ok").unwrap();
1069        crate::stdlib::process::set_thread_execution_context(Some(
1070            crate::orchestration::RunExecutionRecord {
1071                cwd: Some(temp.path().to_string_lossy().into_owned()),
1072                source_dir: Some(temp.path().to_string_lossy().into_owned()),
1073                env: BTreeMap::new(),
1074                adapter: None,
1075                repo_path: None,
1076                worktree_path: None,
1077                branch: None,
1078                base_ref: None,
1079                cleanup: None,
1080            },
1081        ));
1082
1083        let mut tool_annotations = BTreeMap::new();
1084        tool_annotations.insert(
1085            "read_file".to_string(),
1086            ToolAnnotations {
1087                kind: ToolKind::Read,
1088                arg_schema: ToolArgSchema {
1089                    path_params: vec!["path".to_string()],
1090                    ..Default::default()
1091                },
1092                ..Default::default()
1093            },
1094        );
1095        push_execution_policy(CapabilityPolicy {
1096            tool_annotations,
1097            ..Default::default()
1098        });
1099
1100        let policy = ToolApprovalPolicy {
1101            write_path_allowlist: vec!["packages/demo/file.txt".to_string()],
1102            ..Default::default()
1103        };
1104        let decision = policy.evaluate(
1105            "read_file",
1106            &serde_json::json!({"path": "/packages/demo/context.txt"}),
1107        );
1108        assert_eq!(decision, ToolApprovalDecision::AutoApproved);
1109
1110        pop_execution_policy();
1111        crate::stdlib::process::set_thread_execution_context(None);
1112    }
1113
1114    #[test]
1115    fn builtin_policy_covers_fs_read_and_list_helpers() {
1116        clear_execution_policy_stacks();
1117        push_execution_policy(CapabilityPolicy {
1118            capabilities: BTreeMap::from([("workspace".to_string(), vec!["exists".to_string()])]),
1119            side_effect_level: Some("read_only".to_string()),
1120            ..CapabilityPolicy::default()
1121        });
1122
1123        for name in [
1124            "read_lines",
1125            "find_text",
1126            "walk_dir",
1127            "glob",
1128            "project_context_profile_native",
1129        ] {
1130            assert!(
1131                enforce_current_policy_for_builtin(name, &[]).is_err(),
1132                "{name} should be rejected when the matching workspace capability is absent"
1133            );
1134        }
1135
1136        pop_execution_policy();
1137    }
1138
1139    #[test]
1140    fn move_file_requires_workspace_write_side_effect() {
1141        clear_execution_policy_stacks();
1142        push_execution_policy(CapabilityPolicy {
1143            capabilities: BTreeMap::from([(
1144                "workspace".to_string(),
1145                vec!["write_text".to_string()],
1146            )]),
1147            side_effect_level: Some("read_only".to_string()),
1148            ..CapabilityPolicy::default()
1149        });
1150
1151        let error = enforce_current_policy_for_builtin("move_file", &[]).unwrap_err();
1152        assert!(
1153            error.to_string().contains("workspace write ceiling"),
1154            "unexpected error: {error}"
1155        );
1156
1157        pop_execution_policy();
1158    }
1159
1160    #[test]
1161    fn unix_socket_json_request_requires_network_side_effect() {
1162        clear_execution_policy_stacks();
1163        push_execution_policy(CapabilityPolicy {
1164            side_effect_level: Some("read_only".to_string()),
1165            ..CapabilityPolicy::default()
1166        });
1167
1168        let error =
1169            enforce_current_policy_for_builtin("__net_unix_socket_json_request", &[]).unwrap_err();
1170        assert!(
1171            error.to_string().contains("network ceiling"),
1172            "unexpected error: {error}"
1173        );
1174
1175        pop_execution_policy();
1176    }
1177
1178    #[test]
1179    fn files_upload_requires_workspace_read_and_network_side_effect() {
1180        clear_execution_policy_stacks();
1181        push_execution_policy(CapabilityPolicy {
1182            capabilities: BTreeMap::from([(
1183                "workspace".to_string(),
1184                vec!["read_text".to_string()],
1185            )]),
1186            side_effect_level: Some("read_only".to_string()),
1187            ..CapabilityPolicy::default()
1188        });
1189
1190        let network_error = enforce_current_policy_for_builtin("__files_upload", &[]).unwrap_err();
1191        assert!(
1192            network_error.to_string().contains("network ceiling"),
1193            "unexpected error: {network_error}"
1194        );
1195        pop_execution_policy();
1196
1197        push_execution_policy(CapabilityPolicy {
1198            capabilities: BTreeMap::from([("workspace".to_string(), vec!["exists".to_string()])]),
1199            side_effect_level: Some("network".to_string()),
1200            ..CapabilityPolicy::default()
1201        });
1202        let read_error = enforce_current_policy_for_builtin("__files_upload", &[]).unwrap_err();
1203        assert!(
1204            read_error.to_string().contains("workspace.read_text"),
1205            "unexpected error: {read_error}"
1206        );
1207
1208        pop_execution_policy();
1209    }
1210}
1211
1212#[cfg(test)]
1213mod turn_policy_tests {
1214    use super::TurnPolicy;
1215
1216    #[test]
1217    fn default_allows_done_sentinel() {
1218        let policy = TurnPolicy::default();
1219        assert!(policy.allow_done_sentinel);
1220        assert!(!policy.require_action_or_yield);
1221        assert!(policy.max_prose_chars.is_none());
1222    }
1223
1224    #[test]
1225    fn deserializing_partial_dict_preserves_done_sentinel_pathway() {
1226        // Pre-existing workflows passed `turn_policy: { require_action_or_yield: true }`
1227        // without knowing about `allow_done_sentinel`. Deserializing such a dict
1228        // must keep the done-sentinel pathway enabled so loop-until-done agents
1229        // don't lose their completion signal.
1230        let policy: TurnPolicy =
1231            serde_json::from_value(serde_json::json!({ "require_action_or_yield": true }))
1232                .expect("deserialize");
1233        assert!(policy.require_action_or_yield);
1234        assert!(policy.allow_done_sentinel);
1235    }
1236
1237    #[test]
1238    fn deserializing_explicit_false_disables_done_sentinel() {
1239        let policy: TurnPolicy = serde_json::from_value(serde_json::json!({
1240            "require_action_or_yield": true,
1241            "allow_done_sentinel": false,
1242        }))
1243        .expect("deserialize");
1244        assert!(policy.require_action_or_yield);
1245        assert!(!policy.allow_done_sentinel);
1246    }
1247}
1248
1249#[cfg(test)]
1250mod visibility_redaction_tests {
1251    use super::*;
1252    use crate::value::VmValue;
1253
1254    fn mock_transcript() -> VmValue {
1255        let messages = vec![
1256            serde_json::json!({"role": "user", "content": "hi"}),
1257            serde_json::json!({"role": "assistant", "content": "hello"}),
1258            serde_json::json!({"role": "tool_result", "content": "internal tool output"}),
1259        ];
1260        crate::llm::helpers::transcript_to_vm_with_events(
1261            Some("test-id".to_string()),
1262            None,
1263            None,
1264            &messages,
1265            Vec::new(),
1266            Vec::new(),
1267            Some("active"),
1268        )
1269    }
1270
1271    fn message_count(transcript: &VmValue) -> usize {
1272        transcript
1273            .as_dict()
1274            .and_then(|d| d.get("messages"))
1275            .and_then(|v| match v {
1276                VmValue::List(list) => Some(list.len()),
1277                _ => None,
1278            })
1279            .unwrap_or(0)
1280    }
1281
1282    #[test]
1283    fn visibility_none_returns_unchanged() {
1284        let t = mock_transcript();
1285        let result = redact_transcript_visibility(&t, None).unwrap();
1286        assert_eq!(message_count(&result), 3);
1287    }
1288
1289    #[test]
1290    fn visibility_public_drops_tool_results() {
1291        let t = mock_transcript();
1292        let result = redact_transcript_visibility(&t, Some("public")).unwrap();
1293        assert_eq!(message_count(&result), 2);
1294    }
1295
1296    #[test]
1297    fn visibility_public_drops_private_content_blocks() {
1298        let t = crate::schema::json_to_vm_value(&serde_json::json!({
1299            "messages": [
1300                {
1301                    "role": "assistant",
1302                    "visibility": "public",
1303                    "text": "visible answer\nsecret chain",
1304                    "content": [
1305                        {"type": "output_text", "text": "visible answer", "visibility": "public"},
1306                        {"type": "reasoning", "text": "secret chain", "visibility": "private"}
1307                    ],
1308                    "blocks": [
1309                        {"type": "output_text", "text": "visible block", "visibility": "public"},
1310                        {"type": "tool_call", "text": "internal args", "visibility": "internal"}
1311                    ]
1312                }
1313            ],
1314            "events": []
1315        }));
1316
1317        let result = redact_transcript_visibility(&t, Some("public")).unwrap();
1318        let rendered = result.display();
1319        assert!(rendered.contains("visible answer"));
1320        assert!(rendered.contains("visible block"));
1321        assert!(!rendered.contains("secret chain"));
1322        assert!(!rendered.contains("internal args"));
1323    }
1324
1325    #[test]
1326    fn visibility_unknown_string_is_pass_through() {
1327        let t = mock_transcript();
1328        let result = redact_transcript_visibility(&t, Some("internal")).unwrap();
1329        assert_eq!(message_count(&result), 3);
1330    }
1331}