Skip to main content

harn_vm/orchestration/
command_policy.rs

1//! Programmable command-runner policy hooks.
2//!
3//! Command policy is intentionally separate from the generic tool hook
4//! registry: it sees normalized command-runner context before a process
5//! spawns, records deterministic risk labels, and can block or rewrite
6//! constrained request fields without relying on model prompt text.
7
8use crate::value::VmDictExt;
9use std::cell::RefCell;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Component, Path, PathBuf};
12use std::sync::Arc;
13
14use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
15use serde_json::{Map as JsonMap, Value as JsonValue};
16use sha2::{Digest, Sha256};
17
18use crate::value::{VmClosure, VmError, VmValue};
19
20const DEFAULT_SHELL_MODE: &str = "argv_only";
21const INLINE_OUTPUT_LIMIT: usize = 8_192;
22
23thread_local! {
24    static COMMAND_POLICY_STACK: RefCell<Vec<CommandPolicy>> = const { RefCell::new(Vec::new()) };
25    static COMMAND_POLICY_HOOK_DEPTH: RefCell<usize> = const { RefCell::new(0) };
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct CommandPolicy {
30    pub tools: Vec<String>,
31    pub workspace_roots: Vec<String>,
32    pub default_shell_mode: String,
33    pub deny_patterns: Vec<String>,
34    pub require_approval: BTreeSet<String>,
35    /// Risk classes that are hard-denied (blocked before the child spawns) and
36    /// NEVER routed to the consent gate. Distinct from `require_approval`, whose
37    /// labels are consent-eligible. The built-in `catastrophic` label is always
38    /// hard-denied regardless of this set (the never-approvable command floor);
39    /// `deny_labels` lets a policy additionally promote other scanner labels
40    /// (e.g. `destructive`, `network_exfil`) to the same never-approvable tier.
41    pub deny_labels: BTreeSet<String>,
42    pub pre: Option<Arc<VmClosure>>,
43    pub post: Option<Arc<VmClosure>>,
44    /// Optional consent gate for the `require_approval` disposition. When set,
45    /// a command whose risk class is in `require_approval` (or a pre-hook
46    /// `require_approval` decision) is routed through this closure — the
47    /// `std/llm/tool_middleware::with_consent` prompt_fn contract — instead of
48    /// being hard-blocked: `true` / `{decision: "approved"}` lets the command
49    /// run; `false` / `{decision: "denied", reason?}` (and any non-bool,
50    /// non-dict verdict) blocks it with a `consent_denied` disposition. When
51    /// unset, `require_approval` keeps its legacy hard-block behavior so the
52    /// default (no consent) path stays byte-identical.
53    pub consent: Option<Arc<VmClosure>>,
54    pub allow_recursive: bool,
55}
56
57#[derive(Clone, Debug)]
58pub struct CommandPolicyDecision {
59    pub action: String,
60    pub reason: Option<String>,
61    pub source: String,
62    pub risk_labels: Vec<String>,
63    pub confidence: f64,
64    pub display: Option<JsonValue>,
65}
66
67#[derive(Clone, Debug)]
68pub enum CommandPolicyPreflight {
69    Proceed {
70        params: crate::value::DictMap,
71        context: JsonValue,
72        decisions: Vec<CommandPolicyDecision>,
73    },
74    Blocked {
75        status: &'static str,
76        message: String,
77        context: JsonValue,
78        decisions: Vec<CommandPolicyDecision>,
79    },
80}
81
82/// How a command reached the process-policy boundary.
83///
84/// The reviewed structured-git path is deliberately separate from ordinary
85/// `process.exec`: its arguments were constructed by `stdlib.git`, its lease
86/// was checked against the remote, and its risky operation was approved before
87/// this final process dispatch. It may bypass only the generic text classifier's
88/// force-push catastrophe; every configured policy rule still applies.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub(crate) enum CommandDispatchOrigin {
91    ArbitraryProcess,
92    ReviewedGitPushWithLease,
93}
94
95struct HookDepthGuard;
96
97impl Drop for HookDepthGuard {
98    fn drop(&mut self) {
99        COMMAND_POLICY_HOOK_DEPTH.with(|depth| {
100            let mut depth = depth.borrow_mut();
101            *depth = depth.saturating_sub(1);
102        });
103    }
104}
105
106pub fn push_command_policy(policy: CommandPolicy) {
107    COMMAND_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
108}
109
110pub fn pop_command_policy() {
111    COMMAND_POLICY_STACK.with(|stack| {
112        stack.borrow_mut().pop();
113    });
114}
115
116pub fn clear_command_policies() {
117    COMMAND_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
118    COMMAND_POLICY_HOOK_DEPTH.with(|depth| *depth.borrow_mut() = 0);
119}
120
121pub fn current_command_policy() -> Option<CommandPolicy> {
122    COMMAND_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
123}
124
125/// Per-task ambient-scope swap of the command-policy stack and hook depth. See
126/// `orchestration::ambient_scope` — these move whole stacks so a spawned worker
127/// task can carry its own command scope across `.await` without leaking into
128/// cooperatively-scheduled siblings on the same thread.
129pub(crate) fn swap_command_policy_stack(next: Vec<CommandPolicy>) -> Vec<CommandPolicy> {
130    COMMAND_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
131}
132
133pub(crate) fn swap_command_policy_hook_depth(next: usize) -> usize {
134    COMMAND_POLICY_HOOK_DEPTH.with(|depth| std::mem::replace(&mut *depth.borrow_mut(), next))
135}
136
137pub fn command_policy_hook_depth() -> usize {
138    COMMAND_POLICY_HOOK_DEPTH.with(|depth| *depth.borrow())
139}
140
141pub fn parse_command_policy_value(
142    value: Option<&VmValue>,
143    label: &str,
144) -> Result<Option<CommandPolicy>, VmError> {
145    let Some(value) = value else {
146        return Ok(None);
147    };
148    if matches!(value, VmValue::Nil) {
149        return Ok(None);
150    }
151    let Some(map) = value.as_dict() else {
152        return Err(VmError::Runtime(format!(
153            "{label}: command_policy must be a dict"
154        )));
155    };
156    Ok(Some(CommandPolicy {
157        tools: string_list_field(map, "tools")?.unwrap_or_default(),
158        workspace_roots: string_list_field(map, "workspace_roots")?.unwrap_or_default(),
159        default_shell_mode: string_field(map, "default_shell_mode")?
160            .unwrap_or_else(|| DEFAULT_SHELL_MODE.to_string()),
161        deny_patterns: string_list_field(map, "deny_patterns")?.unwrap_or_default(),
162        require_approval: string_list_field(map, "require_approval")?
163            .unwrap_or_default()
164            .into_iter()
165            .collect(),
166        deny_labels: string_list_field(map, "deny_labels")?
167            .unwrap_or_default()
168            .into_iter()
169            .collect(),
170        pre: closure_field(map, "pre")?,
171        post: closure_field(map, "post")?,
172        consent: closure_field(map, "consent")?,
173        allow_recursive: bool_field(map, "allow_recursive")?.unwrap_or(false),
174    }))
175}
176
177pub fn normalize_command_policy_value(config: &VmValue) -> Result<VmValue, VmError> {
178    let Some(map) = config.as_dict() else {
179        return Err(VmError::Runtime(
180            "command_policy: config must be a dict".to_string(),
181        ));
182    };
183    let mut normalized = (*map).clone();
184    normalized
185        .entry(crate::value::intern_key("_type"))
186        .or_insert_with(|| VmValue::String(arcstr::ArcStr::from("command_policy")));
187    normalized
188        .entry(crate::value::intern_key("default_shell_mode"))
189        .or_insert_with(|| VmValue::String(arcstr::ArcStr::from(DEFAULT_SHELL_MODE)));
190    normalized
191        .entry(crate::value::intern_key("workspace_roots"))
192        .or_insert_with(|| VmValue::List(std::sync::Arc::new(Vec::new())));
193    normalized
194        .entry(crate::value::intern_key("deny_patterns"))
195        .or_insert_with(|| VmValue::List(std::sync::Arc::new(Vec::new())));
196    normalized
197        .entry(crate::value::intern_key("require_approval"))
198        .or_insert_with(|| VmValue::List(std::sync::Arc::new(Vec::new())));
199    normalized
200        .entry(crate::value::intern_key("deny_labels"))
201        .or_insert_with(|| VmValue::List(std::sync::Arc::new(Vec::new())));
202    parse_command_policy_value(Some(&VmValue::dict(normalized.clone())), "command_policy")?;
203    Ok(VmValue::dict(normalized))
204}
205
206pub fn command_risk_scan_value(ctx: &VmValue) -> Result<VmValue, VmError> {
207    let json = crate::llm::vm_value_to_json(ctx);
208    let scan = command_risk_scan_json(&json, None);
209    Ok(crate::stdlib::json_to_vm_value(&scan))
210}
211
212pub fn command_result_scan_value(ctx: &VmValue) -> Result<VmValue, VmError> {
213    let json = crate::llm::vm_value_to_json(ctx);
214    let mut labels = Vec::new();
215    let output = inline_output_for_scan(json.pointer("/result/stdout"))
216        + &inline_output_for_scan(json.pointer("/result/stderr"));
217    let lower = output.to_ascii_lowercase();
218    if contains_secret_like_text(&lower) {
219        labels.push("credential_output".to_string());
220    }
221    if lower.contains("permission denied") || lower.contains("operation not permitted") {
222        labels.push("permission_boundary_hit".to_string());
223    }
224    if lower.contains("fatal:") || lower.contains("error:") {
225        labels.push("error_output".to_string());
226    }
227    labels.sort();
228    labels.dedup();
229    let action = if labels.iter().any(|label| label == "credential_output") {
230        "mark_unsafe"
231    } else {
232        "allow"
233    };
234    Ok(crate::stdlib::json_to_vm_value(&serde_json::json!({
235        "action": action,
236        "recommended_action": action,
237        "risk_labels": labels,
238        "confidence": if action == "allow" { 0.35 } else { 0.82 },
239        "rationale": if action == "allow" {
240            "no high-risk command output patterns detected"
241        } else {
242            "command output appears to contain credential-like material"
243        },
244    })))
245}
246
247pub fn command_llm_risk_scan_value(
248    ctx: &VmValue,
249    options: Option<&VmValue>,
250) -> Result<VmValue, VmError> {
251    let mut scan = crate::llm::vm_value_to_json(&command_risk_scan_value(ctx)?);
252    let options_json = options
253        .map(crate::llm::vm_value_to_json)
254        .unwrap_or_else(|| serde_json::json!({}));
255    if let Some(obj) = scan.as_object_mut() {
256        obj.insert(
257            "scan_kind".to_string(),
258            JsonValue::String("deterministic_fallback".to_string()),
259        );
260        obj.insert("llm".to_string(), redact_json_for_llm(&options_json));
261        obj.entry("rationale".to_string()).or_insert_with(|| {
262            JsonValue::String("deterministic fallback used without external model call".to_string())
263        });
264    }
265    Ok(crate::stdlib::json_to_vm_value(&scan))
266}
267
268pub async fn run_command_policy_preflight(
269    params: &crate::value::DictMap,
270    caller: JsonValue,
271) -> Result<CommandPolicyPreflight, VmError> {
272    run_command_policy_preflight_with_ctx(None, params, caller).await
273}
274
275pub async fn run_command_policy_preflight_with_ctx(
276    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
277    params: &crate::value::DictMap,
278    caller: JsonValue,
279) -> Result<CommandPolicyPreflight, VmError> {
280    run_command_policy_preflight_with_origin(
281        ctx,
282        params,
283        caller,
284        CommandDispatchOrigin::ArbitraryProcess,
285    )
286    .await
287}
288
289pub(crate) async fn run_command_policy_preflight_with_origin(
290    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
291    params: &crate::value::DictMap,
292    caller: JsonValue,
293    origin: CommandDispatchOrigin,
294) -> Result<CommandPolicyPreflight, VmError> {
295    let Some(policy) = current_command_policy() else {
296        // No `command_policy` is on the stack. The catastrophic floor still
297        // applies as a universal backstop — a bare `host_process_exec` must not
298        // be able to run `rm -rf /`, a fork bomb, `mkfs`, `dd of=<disk>`, or the
299        // git-destructive family (`git reset --hard`, `git clean -fd`,
300        // force-push) just because no policy was pushed. Structured git
301        // builtins such as `git.push(..., lease)` remain the reviewed path for
302        // legitimate force-with-lease workflows; arbitrary textual git
303        // destructors stay behind the same never-approvable floor as disk/data
304        // destruction. The scan runs on `floor_command_text` exactly as the
305        // policy path does, so the argv-quoting `sh -c "…"` wrapper bypass stays
306        // closed here too.
307        let default_policy = CommandPolicy::default();
308        let context = command_context_json(params, &default_policy, caller);
309        let mut scan = command_risk_scan_json(&context, None);
310        exempt_reviewed_git_push_lease_from_catastrophic_floor(&mut scan, origin);
311        let is_catastrophic = scan
312            .get("catastrophic_reason")
313            .and_then(|value| value.as_str())
314            .is_some();
315        if is_catastrophic {
316            let labels = risk_labels_from_scan(&scan);
317            let deny = hard_deny_decision(&scan, &default_policy, &labels)
318                .expect("catastrophic_reason implies a hard-deny decision");
319            let msg = deny.reason.clone().unwrap_or_default();
320            return Ok(CommandPolicyPreflight::Blocked {
321                status: "blocked",
322                message: msg,
323                context,
324                decisions: vec![deny],
325            });
326        }
327        return Ok(CommandPolicyPreflight::Proceed {
328            params: params.clone(),
329            context: JsonValue::Null,
330            decisions: Vec::new(),
331        });
332    };
333
334    if command_policy_hook_depth() > 0 && !policy.allow_recursive {
335        let context = command_context_json(params, &policy, caller);
336        let decision = decision(
337            "deny",
338            Some("command policy hooks cannot recursively call process.exec".to_string()),
339            "recursion_guard",
340            Vec::new(),
341            1.0,
342        );
343        return Ok(CommandPolicyPreflight::Blocked {
344            status: "blocked",
345            message: decision.reason.clone().unwrap_or_default(),
346            context,
347            decisions: vec![decision],
348        });
349    }
350
351    let mut current_params = params.clone();
352    let mut context = command_context_json(&current_params, &policy, caller);
353    let mut decisions = Vec::new();
354    let mut rewritten_by_hook = false;
355    let mut scan = command_risk_scan_json(&context, Some(&policy));
356    exempt_reviewed_git_push_lease_from_catastrophic_floor(&mut scan, origin);
357    if let Some(labels) = scan.get("risk_labels").and_then(|value| value.as_array()) {
358        let labels = labels
359            .iter()
360            .filter_map(|value| value.as_str().map(ToString::to_string))
361            .collect::<Vec<_>>();
362        if !labels.is_empty() {
363            decisions.push(decision(
364                "classify",
365                scan.get("rationale")
366                    .and_then(|value| value.as_str())
367                    .map(ToString::to_string),
368                "deterministic",
369                labels,
370                scan.get("confidence")
371                    .and_then(|value| value.as_f64())
372                    .unwrap_or(0.7),
373            ));
374        }
375    }
376
377    // Never-approvable command floor: enforced before deny-patterns and before
378    // any consent/approval routing. The child is never spawned and the decision
379    // is never sent to the consent gate.
380    if let Some(deny) = hard_deny_decision(&scan, &policy, &risk_labels_from_scan(&scan)) {
381        let msg = deny.reason.clone().unwrap_or_default();
382        decisions.push(deny);
383        return Ok(CommandPolicyPreflight::Blocked {
384            status: "blocked",
385            message: msg,
386            context,
387            decisions,
388        });
389    }
390
391    if let Some(matched) = first_deny_pattern(&policy, &context) {
392        let msg = if matched.candidate == command_text(&context) {
393            format!("command denied by policy pattern {:?}", matched.pattern)
394        } else {
395            format!(
396                "command segment {:?} denied by policy pattern {:?}",
397                matched.candidate, matched.pattern
398            )
399        };
400        let decision = decision("deny", Some(msg.clone()), "deny_patterns", Vec::new(), 1.0);
401        decisions.push(decision);
402        return Ok(CommandPolicyPreflight::Blocked {
403            status: "blocked",
404            message: msg,
405            context,
406            decisions,
407        });
408    }
409
410    let risk_labels = risk_labels_from_scan(&scan);
411    let matched_approval = risk_labels
412        .iter()
413        .find(|label| policy.require_approval.contains(label.as_str()))
414        .cloned();
415    if let Some(label) = matched_approval {
416        let msg = format!("command requires approval for risk class {label}");
417        decisions.push(decision(
418            "require_approval",
419            Some(msg.clone()),
420            "deterministic",
421            risk_labels.clone(),
422            0.9,
423        ));
424        match command_consent_verdict(ctx, &policy, &context, &risk_labels, &msg).await? {
425            ConsentVerdict::NoGate => {
426                return Ok(CommandPolicyPreflight::Blocked {
427                    status: "blocked",
428                    message: msg,
429                    context,
430                    decisions,
431                });
432            }
433            ConsentVerdict::Denied(reason) => {
434                decisions.push(decision(
435                    "consent_denied",
436                    Some(reason.clone()),
437                    "consent",
438                    risk_labels.clone(),
439                    1.0,
440                ));
441                return Ok(CommandPolicyPreflight::Blocked {
442                    status: "consent_denied",
443                    message: reason,
444                    context,
445                    decisions,
446                });
447            }
448            ConsentVerdict::Approved => {
449                decisions.push(decision(
450                    "consent_granted",
451                    Some(format!("consent granted for {msg}")),
452                    "consent",
453                    risk_labels.clone(),
454                    1.0,
455                ));
456            }
457        }
458    }
459
460    if let Some(pre) = policy.pre.as_ref() {
461        let action = invoke_command_hook(ctx, pre, &context).await?;
462        match parse_pre_hook_action(action)? {
463            ParsedPreHookAction::Allow => {}
464            ParsedPreHookAction::Deny(message) => {
465                decisions.push(decision(
466                    "deny",
467                    Some(message.clone()),
468                    "pre_hook",
469                    risk_labels,
470                    1.0,
471                ));
472                return Ok(CommandPolicyPreflight::Blocked {
473                    status: "blocked",
474                    message,
475                    context,
476                    decisions,
477                });
478            }
479            ParsedPreHookAction::RequireApproval(message, display) => {
480                decisions.push(CommandPolicyDecision {
481                    action: "require_approval".to_string(),
482                    reason: Some(message.clone()),
483                    source: "pre_hook".to_string(),
484                    risk_labels: risk_labels.clone(),
485                    confidence: 1.0,
486                    display,
487                });
488                match command_consent_verdict(ctx, &policy, &context, &risk_labels, &message)
489                    .await?
490                {
491                    ConsentVerdict::NoGate => {
492                        return Ok(CommandPolicyPreflight::Blocked {
493                            status: "blocked",
494                            message,
495                            context,
496                            decisions,
497                        });
498                    }
499                    ConsentVerdict::Denied(reason) => {
500                        decisions.push(decision(
501                            "consent_denied",
502                            Some(reason.clone()),
503                            "consent",
504                            risk_labels,
505                            1.0,
506                        ));
507                        return Ok(CommandPolicyPreflight::Blocked {
508                            status: "consent_denied",
509                            message: reason,
510                            context,
511                            decisions,
512                        });
513                    }
514                    ConsentVerdict::Approved => {
515                        decisions.push(decision(
516                            "consent_granted",
517                            Some(format!("consent granted for {message}")),
518                            "consent",
519                            risk_labels,
520                            1.0,
521                        ));
522                    }
523                }
524            }
525            ParsedPreHookAction::DryRun(message) => {
526                decisions.push(decision(
527                    "dry_run",
528                    Some(message.clone()),
529                    "pre_hook",
530                    risk_labels,
531                    1.0,
532                ));
533                return Ok(CommandPolicyPreflight::Blocked {
534                    status: "dry_run",
535                    message,
536                    context,
537                    decisions,
538                });
539            }
540            ParsedPreHookAction::ExplainOnly(message) => {
541                decisions.push(decision(
542                    "explain_only",
543                    Some(message.clone()),
544                    "pre_hook",
545                    risk_labels,
546                    1.0,
547                ));
548                return Ok(CommandPolicyPreflight::Blocked {
549                    status: "explain_only",
550                    message,
551                    context,
552                    decisions,
553                });
554            }
555            ParsedPreHookAction::Rewrite(rewrite) => {
556                apply_command_rewrite(&mut current_params, &rewrite)?;
557                rewritten_by_hook = true;
558                decisions.push(decision(
559                    "rewrite",
560                    Some("command request rewritten by pre-hook".to_string()),
561                    "pre_hook",
562                    risk_labels,
563                    1.0,
564                ));
565                context = command_context_json(&current_params, &policy, context["caller"].clone());
566            }
567        }
568    }
569
570    if rewritten_by_hook {
571        let mut scan = command_risk_scan_json(&context, Some(&policy));
572        // The host validates a reviewed structured-git argv again after this
573        // preflight returns. Preserve the narrow exemption only until that
574        // second validation; a hook cannot turn a lease push into arbitrary
575        // process execution.
576        exempt_reviewed_git_push_lease_from_catastrophic_floor(&mut scan, origin);
577        // Re-apply the never-approvable floor to the rewritten command: a
578        // pre-hook rewrite must not be able to smuggle a catastrophic command
579        // past the floor.
580        if let Some(deny) = hard_deny_decision(&scan, &policy, &risk_labels_from_scan(&scan)) {
581            let msg = deny.reason.clone().unwrap_or_default();
582            decisions.push(deny);
583            return Ok(CommandPolicyPreflight::Blocked {
584                status: "blocked",
585                message: msg,
586                context,
587                decisions,
588            });
589        }
590        if let Some(matched) = first_deny_pattern(&policy, &context) {
591            let msg = format!("rewritten command denied by policy pattern {matched:?}");
592            decisions.push(decision(
593                "deny",
594                Some(msg.clone()),
595                "deny_patterns",
596                risk_labels_from_scan(&scan),
597                1.0,
598            ));
599            return Ok(CommandPolicyPreflight::Blocked {
600                status: "blocked",
601                message: msg,
602                context,
603                decisions,
604            });
605        }
606        let risk_labels = risk_labels_from_scan(&scan);
607        let matched_approval = risk_labels
608            .iter()
609            .find(|label| policy.require_approval.contains(label.as_str()))
610            .cloned();
611        if let Some(label) = matched_approval {
612            let msg = format!("rewritten command requires approval for risk class {label}");
613            decisions.push(decision(
614                "require_approval",
615                Some(msg.clone()),
616                "deterministic",
617                risk_labels.clone(),
618                0.9,
619            ));
620            match command_consent_verdict(ctx, &policy, &context, &risk_labels, &msg).await? {
621                ConsentVerdict::NoGate => {
622                    return Ok(CommandPolicyPreflight::Blocked {
623                        status: "blocked",
624                        message: msg,
625                        context,
626                        decisions,
627                    });
628                }
629                ConsentVerdict::Denied(reason) => {
630                    decisions.push(decision(
631                        "consent_denied",
632                        Some(reason.clone()),
633                        "consent",
634                        risk_labels,
635                        1.0,
636                    ));
637                    return Ok(CommandPolicyPreflight::Blocked {
638                        status: "consent_denied",
639                        message: reason,
640                        context,
641                        decisions,
642                    });
643                }
644                ConsentVerdict::Approved => {
645                    decisions.push(decision(
646                        "consent_granted",
647                        Some(format!("consent granted for {msg}")),
648                        "consent",
649                        risk_labels,
650                        1.0,
651                    ));
652                }
653            }
654        }
655    }
656
657    Ok(CommandPolicyPreflight::Proceed {
658        params: current_params,
659        context,
660        decisions,
661    })
662}
663
664/// The universal classifier intentionally treats every textual force push as
665/// catastrophic. A structured `git.push` with an exact lease is not an
666/// arbitrary textual command: the builtin validated the operation before this
667/// dispatch and its caller must still satisfy all configured policy rules.
668///
669/// Remove only the classifier's synthetic `catastrophic` fact. Keep
670/// `git_force_push` so a caller's explicit `deny_labels` or approval policy can
671/// still reject or gate the operation. After policy hooks run, the host
672/// revalidates the exact argv before dispatch.
673fn exempt_reviewed_git_push_lease_from_catastrophic_floor(
674    scan: &mut JsonValue,
675    origin: CommandDispatchOrigin,
676) {
677    if origin != CommandDispatchOrigin::ReviewedGitPushWithLease {
678        return;
679    }
680    let has_git_force_push = risk_labels_from_scan(scan)
681        .iter()
682        .any(|label| label == "git_force_push");
683    if !has_git_force_push {
684        return;
685    }
686    if let Some(object) = scan.as_object_mut() {
687        object.remove("catastrophic_reason");
688        if let Some(labels) = object
689            .get_mut("risk_labels")
690            .and_then(JsonValue::as_array_mut)
691        {
692            labels.retain(|label| label.as_str() != Some("catastrophic"));
693        }
694    }
695}
696
697pub async fn run_command_policy_postflight(
698    params: &crate::value::DictMap,
699    result: VmValue,
700    pre_context: JsonValue,
701    decisions: Vec<CommandPolicyDecision>,
702) -> Result<VmValue, VmError> {
703    run_command_policy_postflight_with_ctx(None, params, result, pre_context, decisions).await
704}
705
706pub async fn run_command_policy_postflight_with_ctx(
707    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
708    _params: &crate::value::DictMap,
709    result: VmValue,
710    pre_context: JsonValue,
711    mut decisions: Vec<CommandPolicyDecision>,
712) -> Result<VmValue, VmError> {
713    let Some(policy) = current_command_policy() else {
714        return Ok(result);
715    };
716    let Some(post) = policy.post.as_ref() else {
717        return Ok(attach_policy_audit(result, pre_context, decisions, None));
718    };
719    let mut context = pre_context;
720    let result_json = crate::llm::vm_value_to_json(&result);
721    let mut scan_context = context.clone();
722    if let Some(obj) = scan_context.as_object_mut() {
723        obj.insert("result".to_string(), result_json.clone());
724    }
725    let post_scan = crate::llm::vm_value_to_json(&command_result_scan_value(
726        &crate::stdlib::json_to_vm_value(&scan_context),
727    )?);
728    if let Some(obj) = context.as_object_mut() {
729        obj.insert("result".to_string(), result_json);
730        obj.insert("post_scan".to_string(), post_scan);
731    }
732    let action = invoke_command_hook(ctx, post, &context).await?;
733    let (result, annotation) = parse_post_hook_action(action, result)?;
734    if annotation.is_some() {
735        decisions.push(decision(
736            "annotate",
737            Some("command result annotated by post-hook".to_string()),
738            "post_hook",
739            Vec::new(),
740            1.0,
741        ));
742    }
743    Ok(attach_policy_audit(result, context, decisions, annotation))
744}
745
746pub fn blocked_command_response(
747    params: &crate::value::DictMap,
748    status: &str,
749    message: &str,
750    context: JsonValue,
751    decisions: Vec<CommandPolicyDecision>,
752) -> VmValue {
753    let command_id = format!("cmd_blocked_{}", crate::orchestration::new_id("policy"));
754    let now = chrono::Utc::now().to_rfc3339();
755    let mut result = BTreeMap::new();
756    result.put_str("command_id", command_id.clone());
757    result.put_str("status", status);
758    result.insert("pid".to_string(), VmValue::Nil);
759    result.insert("process_group_id".to_string(), VmValue::Nil);
760    result.insert("handle_id".to_string(), VmValue::Nil);
761    result.put_str("started_at", now.clone());
762    result.put_str("ended_at", now);
763    result.insert("duration_ms".to_string(), VmValue::Int(0));
764    result.insert("exit_code".to_string(), VmValue::Int(-1));
765    result.insert("signal".to_string(), VmValue::Nil);
766    result.insert("timed_out".to_string(), VmValue::Bool(false));
767    result.put_str("stdout", "");
768    result.put_str("stderr", message);
769    result.put_str("combined", message);
770    result.insert("exit_status".to_string(), VmValue::Int(-1));
771    result.insert("legacy_status".to_string(), VmValue::Int(-1));
772    result.insert("success".to_string(), VmValue::Bool(false));
773    result.put_str("error", "permission_denied");
774    result.put_str("reason", message);
775    result.put_str("audit_id", format!("audit_{command_id}"));
776    result.insert(
777        "request".to_string(),
778        VmValue::dict(redacted_vm_request(params)),
779    );
780    attach_policy_audit(VmValue::dict(result), context, decisions, None)
781}
782
783fn attach_policy_audit(
784    result: VmValue,
785    context: JsonValue,
786    decisions: Vec<CommandPolicyDecision>,
787    annotation: Option<JsonValue>,
788) -> VmValue {
789    let Some(map) = result.as_dict() else {
790        return result;
791    };
792    let mut out = (*map).clone();
793    let mut audit = serde_json::json!({
794        "context": context,
795        "decisions": decisions.iter().map(decision_json).collect::<Vec<_>>(),
796    });
797    if let Some(annotation) = annotation {
798        audit["annotation"] = annotation;
799    }
800    out.insert(
801        crate::value::intern_key("command_policy"),
802        crate::stdlib::json_to_vm_value(&audit),
803    );
804    VmValue::dict(out)
805}
806
807fn decision(
808    action: &str,
809    reason: Option<String>,
810    source: &str,
811    risk_labels: Vec<String>,
812    confidence: f64,
813) -> CommandPolicyDecision {
814    CommandPolicyDecision {
815        action: action.to_string(),
816        reason,
817        source: source.to_string(),
818        risk_labels,
819        confidence,
820        display: None,
821    }
822}
823
824fn decision_json(decision: &CommandPolicyDecision) -> JsonValue {
825    serde_json::json!({
826        "action": decision.action,
827        "reason": decision.reason,
828        "source": decision.source,
829        "risk_labels": decision.risk_labels,
830        "confidence": decision.confidence,
831        "display": decision.display,
832    })
833}
834
835async fn invoke_command_hook(
836    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
837    closure: &Arc<VmClosure>,
838    payload: &JsonValue,
839) -> Result<VmValue, VmError> {
840    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
841        return Err(VmError::Runtime(
842            "command policy hook requires an async builtin VM context".to_string(),
843        ));
844    };
845    COMMAND_POLICY_HOOK_DEPTH.with(|depth| *depth.borrow_mut() += 1);
846    let _guard = HookDepthGuard;
847    let arg = crate::stdlib::json_to_vm_value(payload);
848    vm.call_closure_pub(closure, &[arg]).await
849}
850
851/// Outcome of routing a `require_approval` disposition through the policy's
852/// optional consent gate (`CommandPolicy.consent`). Mirrors the
853/// `std/llm/tool_middleware::with_consent` prompt_fn contract.
854#[derive(Clone, Debug)]
855enum ConsentVerdict {
856    /// No consent gate is configured — preserve the legacy hard-block so the
857    /// default (no consent) path stays byte-identical.
858    NoGate,
859    /// The consent callback approved the command; let it run.
860    Approved,
861    /// The consent callback (or a fail-closed default) denied the command.
862    Denied(String),
863}
864
865/// Consult the policy's consent gate for a command that landed on a
866/// `require_approval` disposition. The consent closure receives the command
867/// context enriched with the deterministic classification (`consent.reason`
868/// and `consent.risk_labels`) and returns the `with_consent` prompt_fn shape:
869/// `true` / `{decision: "approved"}` to allow, `false` /
870/// `{decision: "denied", reason?}` to deny. Any non-bool, non-dict verdict is
871/// treated as a denial (fail closed), matching `with_consent`.
872async fn command_consent_verdict(
873    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
874    policy: &CommandPolicy,
875    context: &JsonValue,
876    risk_labels: &[String],
877    reason: &str,
878) -> Result<ConsentVerdict, VmError> {
879    let Some(consent) = policy.consent.as_ref() else {
880        return Ok(ConsentVerdict::NoGate);
881    };
882    let mut consent_ctx = context.clone();
883    if let Some(obj) = consent_ctx.as_object_mut() {
884        obj.insert(
885            "consent".to_string(),
886            serde_json::json!({
887                "reason": reason,
888                "risk_labels": risk_labels,
889            }),
890        );
891    }
892    let outcome = invoke_command_hook(ctx, consent, &consent_ctx).await?;
893    Ok(parse_consent_outcome(outcome, reason))
894}
895
896fn parse_consent_outcome(value: VmValue, reason: &str) -> ConsentVerdict {
897    match value {
898        VmValue::Bool(true) => ConsentVerdict::Approved,
899        VmValue::Bool(false) => ConsentVerdict::Denied(default_consent_denial(reason)),
900        VmValue::Dict(map) => {
901            let verdict = map
902                .get("decision")
903                .map(|value| value.display())
904                .unwrap_or_else(|| "denied".to_string());
905            if verdict == "denied" {
906                let message = map
907                    .get("reason")
908                    .or_else(|| map.get("message"))
909                    .map(|value| value.display())
910                    .unwrap_or_else(|| default_consent_denial(reason));
911                ConsentVerdict::Denied(message)
912            } else {
913                ConsentVerdict::Approved
914            }
915        }
916        // Mirror `with_consent`: a verdict that is neither a bool nor a dict
917        // carries no `decision` key, so it resolves to a denial. Fail closed.
918        _ => ConsentVerdict::Denied(default_consent_denial(reason)),
919    }
920}
921
922fn default_consent_denial(reason: &str) -> String {
923    format!("consent denied: {reason}")
924}
925
926#[derive(Clone, Debug)]
927enum ParsedPreHookAction {
928    Allow,
929    Deny(String),
930    RequireApproval(String, Option<JsonValue>),
931    Rewrite(crate::value::DictMap),
932    DryRun(String),
933    ExplainOnly(String),
934}
935
936fn parse_pre_hook_action(value: VmValue) -> Result<ParsedPreHookAction, VmError> {
937    match value {
938        VmValue::Nil => Ok(ParsedPreHookAction::Allow),
939        VmValue::String(text) if text.as_str() == "allow" => Ok(ParsedPreHookAction::Allow),
940        VmValue::Dict(map) => {
941            if truthy(map.get("allow")) || map.get("action").is_some_and(|v| v.display() == "allow")
942            {
943                return Ok(ParsedPreHookAction::Allow);
944            }
945            if let Some(reason) = map.get("deny").or_else(|| {
946                map.get("message")
947                    .filter(|_| map.get("action").is_some_and(|v| v.display() == "deny"))
948            }) {
949                return Ok(ParsedPreHookAction::Deny(reason.display()));
950            }
951            if map
952                .get("action")
953                .is_some_and(|v| v.display() == "require_approval")
954                || map.contains_key("require_approval")
955            {
956                let message = map
957                    .get("reason")
958                    .or_else(|| map.get("message"))
959                    .or_else(|| map.get("require_approval"))
960                    .map(|v| v.display())
961                    .unwrap_or_else(|| "command requires approval".to_string());
962                let display = map.get("display").map(crate::llm::vm_value_to_json);
963                return Ok(ParsedPreHookAction::RequireApproval(message, display));
964            }
965            if map.get("action").is_some_and(|v| v.display() == "dry_run")
966                || truthy(map.get("dry_run"))
967            {
968                return Ok(ParsedPreHookAction::DryRun(
969                    map.get("reason")
970                        .or_else(|| map.get("message"))
971                        .map(|v| v.display())
972                        .unwrap_or_else(|| "command dry-run requested by policy".to_string()),
973                ));
974            }
975            if map
976                .get("action")
977                .is_some_and(|v| v.display() == "explain_only")
978                || truthy(map.get("explain_only"))
979            {
980                return Ok(ParsedPreHookAction::ExplainOnly(
981                    map.get("reason")
982                        .or_else(|| map.get("message"))
983                        .map(|v| v.display())
984                        .unwrap_or_else(|| "command explanation requested by policy".to_string()),
985                ));
986            }
987            if let Some(rewrite) = map.get("rewrite").or_else(|| map.get("request")) {
988                let Some(rewrite) = rewrite.as_dict() else {
989                    return Err(VmError::Runtime(
990                        "command policy pre-hook rewrite must be a dict".to_string(),
991                    ));
992                };
993                return Ok(ParsedPreHookAction::Rewrite(rewrite.clone()));
994            }
995            Ok(ParsedPreHookAction::Allow)
996        }
997        other => Err(VmError::Runtime(format!(
998            "command policy pre-hook must return nil, 'allow', or a decision dict, got {}",
999            other.type_name()
1000        ))),
1001    }
1002}
1003
1004fn parse_post_hook_action(
1005    value: VmValue,
1006    current_result: VmValue,
1007) -> Result<(VmValue, Option<JsonValue>), VmError> {
1008    match value {
1009        VmValue::Nil => Ok((current_result, None)),
1010        VmValue::Dict(map) => {
1011            let mut result = current_result;
1012            if let Some(replacement) = map.get("result") {
1013                result = replacement.clone();
1014            }
1015            if let Some(feedback) = map.get("feedback").and_then(|v| v.as_dict()) {
1016                let session_id = feedback
1017                    .get("session_id")
1018                    .map(|v| v.display())
1019                    .or_else(crate::llm::current_agent_session_id);
1020                if let Some(session_id) = session_id {
1021                    let kind = feedback
1022                        .get("kind")
1023                        .map(|v| v.display())
1024                        .unwrap_or_else(|| "command_policy".to_string());
1025                    let content =
1026                        feedback
1027                            .get("content")
1028                            .map(|v| v.display())
1029                            .unwrap_or_else(|| {
1030                                crate::llm::vm_value_to_json(&VmValue::dict(feedback.clone()))
1031                                    .to_string()
1032                            });
1033                    crate::orchestration::agent_inbox::push(
1034                        &session_id,
1035                        &kind,
1036                        &content,
1037                        "orchestration.command_policy",
1038                    );
1039                }
1040            }
1041            let annotation = if map.contains_key("unsafe")
1042                || map.contains_key("annotations")
1043                || map.contains_key("audit")
1044            {
1045                Some(crate::llm::vm_value_to_json(&VmValue::Dict(map)))
1046            } else {
1047                None
1048            };
1049            Ok((result, annotation))
1050        }
1051        other => Err(VmError::Runtime(format!(
1052            "command policy post-hook must return nil or a dict, got {}",
1053            other.type_name()
1054        ))),
1055    }
1056}
1057
1058fn apply_command_rewrite(
1059    params: &mut crate::value::DictMap,
1060    rewrite: &crate::value::DictMap,
1061) -> Result<(), VmError> {
1062    for (key, value) in rewrite {
1063        match key.as_str() {
1064            "mode" | "argv" | "command" | "shell" | "cwd" | "env" | "env_remove" | "env_mode"
1065            | "stdin" | "timeout" | "timeout_ms" | "capture" | "capture_stderr"
1066            | "max_inline_bytes" => {
1067                params.insert(key.clone(), value.clone());
1068            }
1069            other => {
1070                return Err(VmError::Runtime(format!(
1071                    "command policy rewrite cannot modify field {other:?}"
1072                )));
1073            }
1074        }
1075    }
1076    Ok(())
1077}
1078
1079fn command_context_json(
1080    params: &crate::value::DictMap,
1081    policy: &CommandPolicy,
1082    caller: JsonValue,
1083) -> JsonValue {
1084    let request = command_request_json(params);
1085    let active_cwd = request
1086        .get("cwd")
1087        .and_then(|value| value.as_str())
1088        .map(ToString::to_string)
1089        .unwrap_or_else(|| {
1090            crate::stdlib::process::execution_root_path()
1091                .display()
1092                .to_string()
1093        });
1094    let workspace_roots = if policy.workspace_roots.is_empty() {
1095        vec![crate::stdlib::process::execution_root_path()
1096            .display()
1097            .to_string()]
1098    } else {
1099        policy.workspace_roots.clone()
1100    };
1101    serde_json::json!({
1102        "request": request,
1103        "active_cwd": active_cwd,
1104        "workspace_roots": workspace_roots,
1105        "policy": {
1106            "default_shell_mode": policy.default_shell_mode,
1107            "deny_patterns": policy.deny_patterns,
1108            "require_approval": policy.require_approval.iter().cloned().collect::<Vec<_>>(),
1109            "deny_labels": policy.deny_labels.iter().cloned().collect::<Vec<_>>(),
1110            "ceiling": crate::orchestration::current_execution_policy(),
1111        },
1112        "tool_annotations": crate::orchestration::current_execution_policy()
1113            .map(|policy| policy.tool_annotations)
1114            .unwrap_or_default(),
1115        "transcript": {
1116            "summary": JsonValue::Null,
1117            "recent_messages": [],
1118            "redacted": true,
1119        },
1120        "caller": caller,
1121    })
1122}
1123
1124fn command_request_json(params: &crate::value::DictMap) -> JsonValue {
1125    let mode = string_field_raw(params, "mode")
1126        .or_else(|| params.get("argv").map(|_| "argv".to_string()))
1127        .unwrap_or_else(|| "shell".to_string());
1128    let command = string_field_raw(params, "command");
1129    let argv = params.get("argv").and_then(|value| match value {
1130        VmValue::List(values) => Some(
1131            values
1132                .iter()
1133                .map(|value| value.display())
1134                .collect::<Vec<_>>(),
1135        ),
1136        _ => None,
1137    });
1138    let stdin = string_field_raw(params, "stdin").unwrap_or_default();
1139    let mut env_diff = JsonMap::new();
1140    if let Some(env) = params.get("env").and_then(|value| value.as_dict()) {
1141        for (key, value) in env.iter() {
1142            env_diff.insert(
1143                key.to_string(),
1144                serde_json::json!({
1145                    "present": true,
1146                    "redacted": true,
1147                    "value_sha256": sha256_hex(value.display().as_bytes()),
1148                }),
1149            );
1150        }
1151    }
1152    serde_json::json!({
1153        "mode": mode,
1154        "argv": argv,
1155        "command": command,
1156        "shell": params.get("shell").map(crate::llm::vm_value_to_json).unwrap_or(JsonValue::Null),
1157        "cwd": string_field_raw(params, "cwd").unwrap_or_else(|| crate::stdlib::process::execution_root_path().display().to_string()),
1158        "env_diff": env_diff,
1159        "env_mode": string_field_raw(params, "env_mode"),
1160        "stdin": {
1161            "size": stdin.len(),
1162            "sha256": if stdin.is_empty() { JsonValue::Null } else { JsonValue::String(sha256_hex(stdin.as_bytes())) },
1163        },
1164        "timeout_ms": params.get("timeout_ms").or_else(|| params.get("timeout")).and_then(vm_i64),
1165    })
1166}
1167
1168mod catastrophic;
1169mod scan;
1170
1171use scan::*;
1172
1173pub fn command_risk_scan_json(ctx: &JsonValue, policy: Option<&CommandPolicy>) -> JsonValue {
1174    scan::scan_command_risk_scan_json(ctx, policy)
1175}
1176
1177/// Universal catastrophic-command floor for resolved argv.
1178pub fn universal_catastrophic_reason(
1179    program: &str,
1180    args: &[String],
1181    workspace_roots: &[String],
1182) -> Option<String> {
1183    scan::scan_universal_catastrophic_reason(program, args, workspace_roots)
1184}
1185
1186#[cfg(test)]
1187mod tests;