Skip to main content

harn_vm/orchestration/policy/
mod.rs

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