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