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