1use 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 pub deny_labels: BTreeSet<String>,
42 pub pre: Option<Arc<VmClosure>>,
43 pub post: Option<Arc<VmClosure>>,
44 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#[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
125pub(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 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(¤t_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 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(¤t_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 exempt_reviewed_git_push_lease_from_catastrophic_floor(&mut scan, origin);
577 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
664fn 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#[derive(Clone, Debug)]
855enum ConsentVerdict {
856 NoGate,
859 Approved,
861 Denied(String),
863}
864
865async 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 _ => 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
1177pub 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;