Skip to main content

harn_vm/orchestration/policy/
mod.rs

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