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};
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 pub deny_labels: BTreeSet<String>,
41 pub pre: Option<Arc<VmClosure>>,
42 pub post: Option<Arc<VmClosure>>,
43 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#[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
124pub(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 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(¤t_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 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(¤t_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 exempt_reviewed_git_push_lease_from_catastrophic_floor(&mut scan, origin);
576 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
663fn 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#[derive(Clone, Debug)]
854enum ConsentVerdict {
855 NoGate,
858 Approved,
860 Denied(String),
862}
863
864async 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 _ => 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
1176pub 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;