1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde_json::Value as JsonValue;
7
8use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
9use crate::value::{values_equal, VmError, VmValue};
10use crate::vm::{AsyncBuiltinCtx, Vm};
11
12fn audited_utc_now_rfc3339(capability_id: &'static str) -> String {
16 let dt: chrono::DateTime<chrono::Utc> =
17 crate::clock_mock::leak_audit::wall_now(capability_id).into();
18 dt.to_rfc3339()
19}
20
21pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
22 &HOST_MOCK_BUILTIN_DEF,
23 &HOST_MOCK_CLEAR_BUILTIN_DEF,
24 &HOST_MOCK_CALLS_BUILTIN_DEF,
25 &HOST_MOCK_PUSH_SCOPE_BUILTIN_DEF,
26 &HOST_MOCK_POP_SCOPE_BUILTIN_DEF,
27 &HOST_CAPABILITIES_BUILTIN_DEF,
28 &HOST_HAS_BUILTIN_DEF,
29 &HOST_CALL_BUILTIN_DEF,
30 &HOST_TOOL_LIST_BUILTIN_DEF,
31 &HOST_TOOL_CALL_BUILTIN_DEF,
32];
33
34#[derive(Clone)]
35struct HostMock {
36 capability: String,
37 operation: String,
38 params: Option<BTreeMap<String, VmValue>>,
39 result: Option<VmValue>,
40 error: Option<String>,
41}
42
43#[derive(Clone)]
44struct HostMockCall {
45 capability: String,
46 operation: String,
47 params: BTreeMap<String, VmValue>,
48}
49
50thread_local! {
51 static HOST_MOCKS: RefCell<Vec<HostMock>> = const { RefCell::new(Vec::new()) };
52 static HOST_MOCK_CALLS: RefCell<Vec<HostMockCall>> = const { RefCell::new(Vec::new()) };
53 static HOST_MOCK_SCOPES: RefCell<Vec<(Vec<HostMock>, Vec<HostMockCall>)>> =
54 const { RefCell::new(Vec::new()) };
55}
56
57pub(crate) fn reset_host_state() {
58 HOST_MOCKS.with(|mocks| mocks.borrow_mut().clear());
59 HOST_MOCK_CALLS.with(|calls| calls.borrow_mut().clear());
60 HOST_MOCK_SCOPES.with(|scopes| scopes.borrow_mut().clear());
61}
62
63fn push_host_mock_scope() {
68 let mocks = HOST_MOCKS.with(|v| std::mem::take(&mut *v.borrow_mut()));
69 let calls = HOST_MOCK_CALLS.with(|v| std::mem::take(&mut *v.borrow_mut()));
70 HOST_MOCK_SCOPES.with(|v| v.borrow_mut().push((mocks, calls)));
71}
72
73fn pop_host_mock_scope() -> bool {
78 let entry = HOST_MOCK_SCOPES.with(|v| v.borrow_mut().pop());
79 match entry {
80 Some((mocks, calls)) => {
81 HOST_MOCKS.with(|v| *v.borrow_mut() = mocks);
82 HOST_MOCK_CALLS.with(|v| *v.borrow_mut() = calls);
83 true
84 }
85 None => false,
86 }
87}
88
89fn capability_manifest_map() -> BTreeMap<String, VmValue> {
90 let mut root = BTreeMap::new();
91 root.insert(
92 "process".to_string(),
93 capability(
94 "Process execution.",
95 &[
96 op("exec", "Execute a process in argv or shell mode."),
97 op("list_shells", "List shells discovered by the host/session."),
98 op(
99 "get_default_shell",
100 "Return the selected default shell for this host/session.",
101 ),
102 op(
103 "set_default_shell",
104 "Select the default shell for this host/session.",
105 ),
106 op(
107 "shell_invocation",
108 "Resolve shell selection and login/interactive flags into argv.",
109 ),
110 ],
111 ),
112 );
113 root.insert(
114 "template".to_string(),
115 capability(
116 "Template rendering.",
117 &[op("render", "Render a template file.")],
118 ),
119 );
120 root.insert(
121 "interaction".to_string(),
122 capability(
123 "User interaction.",
124 &[op("ask", "Ask the user a question.")],
125 ),
126 );
127 root.insert(
128 "memory".to_string(),
129 capability(
130 "Vector-aware memory: host-provided embeddings.",
131 &[op(
132 "embed",
133 "Embed text for semantic recall. Params: {text, model_hint?}. \
134 Returns {vector: list<float>, model: string, dim: int}.",
135 )],
136 ),
137 );
138 root
139}
140
141fn mocked_operation_entry() -> VmValue {
142 op(
143 "mocked",
144 "Mocked host operation registered at runtime for tests.",
145 )
146 .1
147}
148
149fn ensure_mocked_capability(
150 root: &mut BTreeMap<String, VmValue>,
151 capability_name: &str,
152 operation_name: &str,
153) {
154 let Some(existing) = root.get(capability_name).cloned() else {
155 root.insert(
156 capability_name.to_string(),
157 capability(
158 "Mocked host capability registered at runtime for tests.",
159 &[(operation_name.to_string(), mocked_operation_entry())],
160 ),
161 );
162 return;
163 };
164
165 let Some(existing_dict) = existing.as_dict() else {
166 return;
167 };
168 let mut entry = (*existing_dict).clone();
169 let mut ops = entry
170 .get("ops")
171 .and_then(|value| match value {
172 VmValue::List(list) => Some((**list).clone()),
173 _ => None,
174 })
175 .unwrap_or_default();
176 if !ops.iter().any(|value| value.display() == operation_name) {
177 ops.push(VmValue::String(std::sync::Arc::from(
178 operation_name.to_string(),
179 )));
180 }
181
182 let mut operations = entry
183 .get("operations")
184 .and_then(|value| value.as_dict())
185 .map(|dict| (*dict).clone())
186 .unwrap_or_default();
187 operations
188 .entry(operation_name.to_string())
189 .or_insert_with(mocked_operation_entry);
190
191 entry.insert("ops".to_string(), VmValue::List(std::sync::Arc::new(ops)));
192 entry.insert(
193 "operations".to_string(),
194 VmValue::Dict(std::sync::Arc::new(operations)),
195 );
196 root.insert(
197 capability_name.to_string(),
198 VmValue::Dict(std::sync::Arc::new(entry)),
199 );
200}
201
202fn capability_manifest_with_mocks() -> VmValue {
203 let mut root = capability_manifest_map();
204 HOST_MOCKS.with(|mocks| {
205 for host_mock in mocks.borrow().iter() {
206 ensure_mocked_capability(&mut root, &host_mock.capability, &host_mock.operation);
207 }
208 });
209 VmValue::Dict(std::sync::Arc::new(root))
210}
211
212fn op(name: &str, description: &str) -> (String, VmValue) {
213 let mut entry = BTreeMap::new();
214 entry.insert(
215 "description".to_string(),
216 VmValue::String(std::sync::Arc::from(description)),
217 );
218 (name.to_string(), VmValue::Dict(std::sync::Arc::new(entry)))
219}
220
221fn capability(description: &str, ops: &[(String, VmValue)]) -> VmValue {
222 let mut entry = BTreeMap::new();
223 entry.insert(
224 "description".to_string(),
225 VmValue::String(std::sync::Arc::from(description)),
226 );
227 entry.insert(
228 "ops".to_string(),
229 VmValue::List(std::sync::Arc::new(
230 ops.iter()
231 .map(|(name, _)| VmValue::String(std::sync::Arc::from(name.as_str())))
232 .collect(),
233 )),
234 );
235 let mut op_dict = BTreeMap::new();
236 for (name, op) in ops {
237 op_dict.insert(name.clone(), op.clone());
238 }
239 entry.insert(
240 "operations".to_string(),
241 VmValue::Dict(std::sync::Arc::new(op_dict)),
242 );
243 VmValue::Dict(std::sync::Arc::new(entry))
244}
245
246fn require_param(params: &BTreeMap<String, VmValue>, key: &str) -> Result<String, VmError> {
247 params
248 .get(key)
249 .map(|v| v.display())
250 .filter(|v| !v.is_empty())
251 .ok_or_else(|| {
252 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
253 "host_call: missing required parameter '{key}'"
254 ))))
255 })
256}
257
258fn render_template(
259 path: &str,
260 bindings: Option<&BTreeMap<String, VmValue>>,
261) -> Result<String, VmError> {
262 let asset = crate::stdlib::template::TemplateAsset::render_target(path).map_err(|msg| {
263 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
264 "host_call template.render: {msg}"
265 ))))
266 })?;
267 crate::stdlib::template::render_asset_result(&asset, bindings).map_err(VmError::from)
268}
269
270fn params_match(
271 expected: Option<&BTreeMap<String, VmValue>>,
272 actual: &BTreeMap<String, VmValue>,
273) -> bool {
274 let Some(expected) = expected else {
275 return true;
276 };
277 expected.iter().all(|(key, value)| {
278 actual
279 .get(key)
280 .is_some_and(|candidate| values_equal(candidate, value))
281 })
282}
283
284fn parse_host_mock(args: &[VmValue]) -> Result<HostMock, VmError> {
285 let capability = args
286 .first()
287 .map(|value| value.display())
288 .unwrap_or_default();
289 let operation = args.get(1).map(|value| value.display()).unwrap_or_default();
290 if capability.is_empty() || operation.is_empty() {
291 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
292 "host_mock: capability and operation are required",
293 ))));
294 }
295
296 let mut params = args
297 .get(3)
298 .and_then(|value| value.as_dict())
299 .map(|dict| (*dict).clone());
300 let mut result = args.get(2).cloned().or(Some(VmValue::Nil));
301 let mut error = None;
302
303 if let Some(config) = args.get(2).and_then(|value| value.as_dict()) {
304 if config.contains_key("result")
305 || config.contains_key("params")
306 || config.contains_key("error")
307 {
308 params = config
309 .get("params")
310 .and_then(|value| value.as_dict())
311 .map(|dict| (*dict).clone());
312 result = config.get("result").cloned();
313 error = config
314 .get("error")
315 .map(|value| value.display())
316 .filter(|value| !value.is_empty());
317 }
318 }
319
320 Ok(HostMock {
321 capability,
322 operation,
323 params,
324 result,
325 error,
326 })
327}
328
329fn push_host_mock(host_mock: HostMock) {
330 HOST_MOCKS.with(|mocks| mocks.borrow_mut().push(host_mock));
331}
332
333fn mock_call_value(call: &HostMockCall) -> VmValue {
334 let mut item = BTreeMap::new();
335 item.insert(
336 "capability".to_string(),
337 VmValue::String(std::sync::Arc::from(call.capability.clone())),
338 );
339 item.insert(
340 "operation".to_string(),
341 VmValue::String(std::sync::Arc::from(call.operation.clone())),
342 );
343 item.insert(
344 "params".to_string(),
345 VmValue::Dict(std::sync::Arc::new(call.params.clone())),
346 );
347 VmValue::Dict(std::sync::Arc::new(item))
348}
349
350fn record_mock_call(capability: &str, operation: &str, params: &BTreeMap<String, VmValue>) {
351 HOST_MOCK_CALLS.with(|calls| {
352 calls.borrow_mut().push(HostMockCall {
353 capability: capability.to_string(),
354 operation: operation.to_string(),
355 params: params.clone(),
356 });
357 });
358}
359
360pub(crate) fn dispatch_mock_host_call(
361 capability: &str,
362 operation: &str,
363 params: &BTreeMap<String, VmValue>,
364) -> Option<Result<VmValue, VmError>> {
365 let matched = HOST_MOCKS.with(|mocks| {
366 mocks
367 .borrow()
368 .iter()
369 .rev()
370 .find(|host_mock| {
371 host_mock.capability == capability
372 && host_mock.operation == operation
373 && params_match(host_mock.params.as_ref(), params)
374 })
375 .cloned()
376 })?;
377
378 record_mock_call(capability, operation, params);
379 if let Some(error) = matched.error {
380 return Some(Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
381 error,
382 )))));
383 }
384 Some(Ok(matched.result.unwrap_or(VmValue::Nil)))
385}
386
387pub trait HostCallBridge: Send + Sync {
402 fn dispatch(
403 &self,
404 capability: &str,
405 operation: &str,
406 params: &BTreeMap<String, VmValue>,
407 ) -> Result<Option<VmValue>, VmError>;
408
409 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
410 Ok(None)
411 }
412
413 fn call_tool(&self, _name: &str, _args: &VmValue) -> Result<Option<VmValue>, VmError> {
414 Ok(None)
415 }
416}
417
418thread_local! {
419 static HOST_CALL_BRIDGE: RefCell<Option<Arc<dyn HostCallBridge>>> = const { RefCell::new(None) };
420}
421
422pub fn set_host_call_bridge(bridge: Arc<dyn HostCallBridge>) {
427 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = Some(bridge));
428}
429
430pub fn clear_host_call_bridge() {
432 HOST_CALL_BRIDGE.with(|b| *b.borrow_mut() = None);
433}
434
435pub fn dispatch_host_call_bridge(
445 capability: &str,
446 operation: &str,
447 params: &BTreeMap<String, VmValue>,
448) -> Option<Result<VmValue, VmError>> {
449 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone())?;
450 match bridge.dispatch(capability, operation, params) {
451 Ok(Some(value)) => Some(Ok(value)),
452 Ok(None) => None,
453 Err(error) => Some(Err(error)),
454 }
455}
456
457fn empty_tool_list_value() -> VmValue {
458 VmValue::List(std::sync::Arc::new(Vec::new()))
459}
460
461fn current_vm_host_bridge(
462 ctx: Option<&AsyncBuiltinCtx>,
463) -> Option<std::sync::Arc<crate::bridge::HostBridge>> {
464 ctx.and_then(|ctx| ctx.child_vm().bridge.clone())
465}
466
467#[cfg(test)]
468async fn dispatch_host_tool_list() -> Result<VmValue, VmError> {
469 dispatch_host_tool_list_with_ctx(None).await
470}
471
472async fn dispatch_host_tool_list_with_ctx(
473 ctx: Option<&AsyncBuiltinCtx>,
474) -> Result<VmValue, VmError> {
475 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
476 if let Some(bridge) = bridge {
477 if let Some(value) = bridge.list_tools()? {
478 return Ok(value);
479 }
480 }
481
482 let Some(bridge) = current_vm_host_bridge(ctx) else {
483 return Ok(empty_tool_list_value());
484 };
485 let tools = bridge.list_host_tools().await?;
486 Ok(crate::bridge::json_result_to_vm_value(&JsonValue::Array(
487 tools.into_iter().collect(),
488 )))
489}
490
491pub(crate) async fn dispatch_host_tool_call(
492 name: &str,
493 args: &VmValue,
494) -> Result<VmValue, VmError> {
495 dispatch_host_tool_call_with_ctx(None, name, args).await
496}
497
498pub(crate) async fn dispatch_host_tool_call_with_ctx(
499 ctx: Option<&AsyncBuiltinCtx>,
500 name: &str,
501 args: &VmValue,
502) -> Result<VmValue, VmError> {
503 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
504 if let Some(bridge) = bridge {
505 if let Some(value) = bridge.call_tool(name, args)? {
506 return Ok(value);
507 }
508 }
509
510 let Some(bridge) = current_vm_host_bridge(ctx) else {
511 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
512 "host_tool_call: no host bridge is attached",
513 ))));
514 };
515
516 let result = bridge
517 .call(
518 "builtin_call",
519 serde_json::json!({
520 "name": name,
521 "args": [crate::llm::vm_value_to_json(args)],
522 }),
523 )
524 .await?;
525 Ok(crate::bridge::json_result_to_vm_value(&result))
526}
527
528pub(crate) async fn dispatch_host_operation(
529 capability: &str,
530 operation: &str,
531 params: &BTreeMap<String, VmValue>,
532) -> Result<VmValue, VmError> {
533 dispatch_host_operation_with_ctx(None, capability, operation, params).await
534}
535
536pub(crate) async fn dispatch_host_operation_with_ctx(
537 ctx: Option<&AsyncBuiltinCtx>,
538 capability: &str,
539 operation: &str,
540 params: &BTreeMap<String, VmValue>,
541) -> Result<VmValue, VmError> {
542 if let Some(mocked) = dispatch_mock_host_call(capability, operation, params) {
543 return mocked;
544 }
545
546 if (capability, operation) == ("process", "exec") {
547 let caller = serde_json::json!({
548 "surface": "host_call",
549 "capability": "process",
550 "operation": "exec",
551 "session_id": crate::llm::current_agent_session_id(),
552 });
553 return dispatch_process_exec_with_policy(ctx, params, caller).await;
554 }
555
556 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
557 if let Some(bridge) = bridge {
558 if let Some(value) = bridge.dispatch(capability, operation, params)? {
559 return Ok(value);
560 }
561 }
562
563 dispatch_builtin_host_operation(capability, operation, params).await
564}
565
566async fn dispatch_builtin_host_operation(
567 capability: &str,
568 operation: &str,
569 params: &BTreeMap<String, VmValue>,
570) -> Result<VmValue, VmError> {
571 match (capability, operation) {
572 ("process", "list_shells") => Ok(crate::shells::list_shells_vm_value()),
573 ("process", "get_default_shell") => Ok(crate::shells::default_shell_vm_value()),
574 ("process", "set_default_shell") => crate::shells::set_default_shell_vm_value(params),
575 ("process", "shell_invocation") => crate::shells::shell_invocation_vm_value(params),
576 ("template", "render") => {
577 let path = require_param(params, "path")?;
578 let bindings = params.get("bindings").and_then(|v| v.as_dict());
579 Ok(VmValue::String(std::sync::Arc::from(render_template(
580 &path, bindings,
581 )?)))
582 }
583 ("interaction", "ask") => {
584 let question = require_param(params, "question")?;
585 use std::io::BufRead;
586 print!("{question}");
587 let _ = std::io::Write::flush(&mut std::io::stdout());
588 let mut input = String::new();
589 if std::io::stdin().lock().read_line(&mut input).is_ok() {
590 Ok(VmValue::String(std::sync::Arc::from(input.trim_end())))
591 } else {
592 Ok(VmValue::Nil)
593 }
594 }
595 ("runtime", "task") => Ok(VmValue::String(std::sync::Arc::from(
600 std::env::var("HARN_TASK").unwrap_or_default(),
601 ))),
602 ("runtime", "set_result") => {
603 Ok(VmValue::Nil)
606 }
607 ("workspace", "project_root") => {
608 let path = std::env::var("HARN_PROJECT_ROOT").unwrap_or_else(|_| {
612 std::env::current_dir()
613 .map(|p| p.display().to_string())
614 .unwrap_or_default()
615 });
616 Ok(VmValue::String(std::sync::Arc::from(path)))
617 }
618 ("workspace", "cwd") => {
619 let path = std::env::current_dir()
620 .map(|p| p.display().to_string())
621 .unwrap_or_default();
622 Ok(VmValue::String(std::sync::Arc::from(path)))
623 }
624 _ => Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
625 format!("host_call: unsupported operation {capability}.{operation}"),
626 )))),
627 }
628}
629
630pub(crate) async fn dispatch_process_exec(
631 params: &BTreeMap<String, VmValue>,
632 caller: serde_json::Value,
633) -> Result<VmValue, VmError> {
634 dispatch_process_exec_with_policy(None, params, caller).await
635}
636
637async fn dispatch_process_exec_with_policy(
638 ctx: Option<&AsyncBuiltinCtx>,
639 params: &BTreeMap<String, VmValue>,
640 caller: serde_json::Value,
641) -> Result<VmValue, VmError> {
642 let (params, command_policy_context, command_policy_decisions) =
643 match crate::orchestration::run_command_policy_preflight_with_ctx(ctx, params, caller)
644 .await?
645 {
646 crate::orchestration::CommandPolicyPreflight::Proceed {
647 params,
648 context,
649 decisions,
650 } => (params, context, decisions),
651 crate::orchestration::CommandPolicyPreflight::Blocked {
652 status,
653 message,
654 context,
655 decisions,
656 } => {
657 return Ok(crate::orchestration::blocked_command_response(
658 params, status, &message, context, decisions,
659 ));
660 }
661 };
662
663 let bridge = HOST_CALL_BRIDGE.with(|b| b.borrow().clone());
664 if let Some(bridge) = bridge {
665 if let Some(value) = bridge.dispatch("process", "exec", ¶ms)? {
666 return crate::orchestration::run_command_policy_postflight_with_ctx(
667 ctx,
668 ¶ms,
669 value,
670 command_policy_context,
671 command_policy_decisions,
672 )
673 .await;
674 }
675 }
676
677 dispatch_process_exec_after_policy(
678 ctx,
679 ¶ms,
680 command_policy_context,
681 command_policy_decisions,
682 )
683 .await
684}
685
686async fn dispatch_process_exec_after_policy(
687 ctx: Option<&AsyncBuiltinCtx>,
688 params: &BTreeMap<String, VmValue>,
689 command_policy_context: JsonValue,
690 command_policy_decisions: Vec<crate::orchestration::CommandPolicyDecision>,
691) -> Result<VmValue, VmError> {
692 let (program, args) = process_exec_argv(params)?;
693 let timeout_ms = optional_i64(params, "timeout")
694 .or_else(|| optional_i64(params, "timeout_ms"))
695 .filter(|value| *value > 0)
696 .map(|value| value as u64);
697 let profile_guard = match optional_string(params, "sandbox_profile") {
703 Some(value) => Some(push_sandbox_profile_override(&value)?),
704 None => None,
705 };
706 let mut cmd = crate::process_sandbox::tokio_command_for(&program, &args)
707 .map_err(|e| VmError::Runtime(format!("host_call process.exec sandbox setup: {e}")))?;
708 if let Some(cwd) = optional_string(params, "cwd") {
709 let cwd = resolve_process_exec_cwd(&cwd);
710 crate::process_sandbox::enforce_process_cwd(&cwd)
711 .map_err(|e| VmError::Runtime(format!("host_call process.exec cwd: {e}")))?;
712 cmd.current_dir(cwd);
713 }
714 if let Some(env) = optional_string_dict(params, "env")? {
715 let env_mode = optional_string(params, "env_mode");
716 if env_mode.as_deref().unwrap_or("replace") == "replace" {
717 cmd.env_clear();
718 }
719 for (key, value) in env {
720 cmd.env(key, value);
721 }
722 }
723 if let Some(env_remove) = optional_string_list(params, "env_remove") {
729 for key in env_remove {
730 cmd.env_remove(key);
731 }
732 }
733 cmd.stdin(std::process::Stdio::null())
734 .stdout(std::process::Stdio::piped())
735 .stderr(std::process::Stdio::piped())
736 .kill_on_drop(true);
737 let started_at = audited_utc_now_rfc3339("host_call/process.exec.started_at");
738 let started = crate::clock_mock::leak_audit::instant_now("host_call/process.exec.started");
739 let child = cmd
740 .spawn()
741 .map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
742 drop(profile_guard);
743 let pid = child.id();
744 let timed_out;
745 let output_result = if let Some(timeout_ms) = timeout_ms {
746 match tokio::time::timeout(
747 std::time::Duration::from_millis(timeout_ms),
748 child.wait_with_output(),
749 )
750 .await
751 {
752 Ok(result) => {
753 timed_out = false;
754 result
755 }
756 Err(_) => {
757 let response = process_exec_response(ProcessExecResponse {
758 pid,
759 started_at,
760 started,
761 stdout: "",
762 stderr: "",
763 exit_code: -1,
764 status: "timed_out",
765 success: false,
766 timed_out: true,
767 });
768 return crate::orchestration::run_command_policy_postflight_with_ctx(
769 ctx,
770 params,
771 response,
772 command_policy_context,
773 command_policy_decisions,
774 )
775 .await;
776 }
777 }
778 } else {
779 timed_out = false;
780 child.wait_with_output().await
781 };
782 let output =
783 output_result.map_err(|e| VmError::Runtime(format!("host_call process.exec: {e}")))?;
784 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
785 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
786 let exit_code = output.status.code().unwrap_or(-1);
787 let response = process_exec_response(ProcessExecResponse {
788 pid,
789 started_at,
790 started,
791 stdout: &stdout,
792 stderr: &stderr,
793 exit_code,
794 status: if timed_out { "timed_out" } else { "completed" },
795 success: output.status.success(),
796 timed_out,
797 });
798 crate::orchestration::run_command_policy_postflight_with_ctx(
799 ctx,
800 params,
801 response,
802 command_policy_context,
803 command_policy_decisions,
804 )
805 .await
806}
807
808struct ProcessExecResponse<'a> {
809 pid: Option<u32>,
810 started_at: String,
811 started: Instant,
812 stdout: &'a str,
813 stderr: &'a str,
814 exit_code: i32,
815 status: &'a str,
816 success: bool,
817 timed_out: bool,
818}
819
820fn process_exec_response(response: ProcessExecResponse<'_>) -> VmValue {
821 let combined = format!("{}{}", response.stdout, response.stderr);
822 let mut result = BTreeMap::new();
823 result.insert(
824 "command_id".to_string(),
825 VmValue::String(std::sync::Arc::from(format!(
826 "cmd_{}_{}",
827 std::process::id(),
828 response.started.elapsed().as_nanos()
829 ))),
830 );
831 result.insert(
832 "status".to_string(),
833 VmValue::String(std::sync::Arc::from(response.status)),
834 );
835 result.insert(
836 "pid".to_string(),
837 response
838 .pid
839 .map(|pid| VmValue::Int(pid as i64))
840 .unwrap_or(VmValue::Nil),
841 );
842 result.insert(
843 "process_group_id".to_string(),
844 response
845 .pid
846 .map(|pid| VmValue::Int(pid as i64))
847 .unwrap_or(VmValue::Nil),
848 );
849 result.insert("handle_id".to_string(), VmValue::Nil);
850 result.insert(
851 "started_at".to_string(),
852 VmValue::String(std::sync::Arc::from(response.started_at)),
853 );
854 result.insert(
855 "ended_at".to_string(),
856 VmValue::String(std::sync::Arc::from(audited_utc_now_rfc3339(
857 "host_call/process.exec.ended_at",
858 ))),
859 );
860 result.insert(
861 "duration_ms".to_string(),
862 VmValue::Int(response.started.elapsed().as_millis() as i64),
863 );
864 result.insert(
865 "exit_code".to_string(),
866 VmValue::Int(response.exit_code as i64),
867 );
868 result.insert("signal".to_string(), VmValue::Nil);
869 result.insert("timed_out".to_string(), VmValue::Bool(response.timed_out));
870 result.insert(
871 "stdout".to_string(),
872 VmValue::String(std::sync::Arc::from(response.stdout.to_string())),
873 );
874 result.insert(
875 "stderr".to_string(),
876 VmValue::String(std::sync::Arc::from(response.stderr.to_string())),
877 );
878 result.insert(
879 "combined".to_string(),
880 VmValue::String(std::sync::Arc::from(combined)),
881 );
882 result.insert(
883 "exit_status".to_string(),
884 VmValue::Int(response.exit_code as i64),
885 );
886 result.insert(
887 "legacy_status".to_string(),
888 VmValue::Int(response.exit_code as i64),
889 );
890 result.insert("success".to_string(), VmValue::Bool(response.success));
891 VmValue::Dict(std::sync::Arc::new(result))
892}
893
894fn resolve_process_exec_cwd(cwd: &str) -> std::path::PathBuf {
895 crate::stdlib::process::resolve_source_relative_path(cwd)
896}
897
898fn process_exec_argv(params: &BTreeMap<String, VmValue>) -> Result<(String, Vec<String>), VmError> {
899 match optional_string(params, "mode")
900 .as_deref()
901 .unwrap_or("shell")
902 {
903 "argv" => {
904 let argv = optional_string_list(params, "argv").ok_or_else(|| {
905 VmError::Runtime("host_call process.exec missing argv".to_string())
906 })?;
907 split_argv(argv)
908 }
909 "shell" => {
910 let command = require_param(params, "command")?;
911 let mut invocation_params = params.clone();
912 invocation_params.insert(
913 "command".to_string(),
914 VmValue::String(std::sync::Arc::from(command)),
915 );
916 let invocation =
917 crate::shells::resolve_invocation_from_vm_params(&invocation_params)
918 .map_err(|err| VmError::Runtime(format!("host_call process.exec: {err}")))?;
919 Ok((invocation.program, invocation.args))
920 }
921 other => Err(VmError::Runtime(format!(
922 "host_call process.exec unsupported mode {other:?}"
923 ))),
924 }
925}
926
927fn split_argv(mut argv: Vec<String>) -> Result<(String, Vec<String>), VmError> {
928 if argv.is_empty() {
929 return Err(VmError::Runtime(
930 "host_call process.exec argv must not be empty".to_string(),
931 ));
932 }
933 let program = argv.remove(0);
934 if program.is_empty() {
935 return Err(VmError::Runtime(
936 "host_call process.exec argv[0] must not be empty".to_string(),
937 ));
938 }
939 Ok((program, argv))
940}
941
942fn push_sandbox_profile_override(value: &str) -> Result<SandboxProfileGuard, VmError> {
948 let profile = crate::orchestration::SandboxProfile::parse(value).ok_or_else(|| {
949 VmError::Thrown(VmValue::String(std::sync::Arc::from(format!(
950 "host_call process.exec: unknown sandbox_profile {value:?}; expected one of \"unrestricted\", \"worktree\", \"os_hardened\", \"wasi\""
951 ))))
952 })?;
953 let mut policy = crate::orchestration::current_execution_policy().unwrap_or_default();
954 policy.sandbox_profile = profile;
955 crate::orchestration::push_execution_policy(policy);
956 Ok(SandboxProfileGuard {
957 _private: std::marker::PhantomData,
958 })
959}
960
961struct SandboxProfileGuard {
962 _private: std::marker::PhantomData<*const ()>,
963}
964
965impl Drop for SandboxProfileGuard {
966 fn drop(&mut self) {
967 crate::orchestration::pop_execution_policy();
968 }
969}
970
971fn optional_i64(params: &BTreeMap<String, VmValue>, key: &str) -> Option<i64> {
972 match params.get(key) {
973 Some(VmValue::Int(value)) => Some(*value),
974 Some(VmValue::Float(value)) if value.fract() == 0.0 => Some(*value as i64),
975 _ => None,
976 }
977}
978
979fn optional_string(params: &BTreeMap<String, VmValue>, key: &str) -> Option<String> {
980 params.get(key).and_then(vm_string).map(ToString::to_string)
981}
982
983fn optional_string_list(params: &BTreeMap<String, VmValue>, key: &str) -> Option<Vec<String>> {
984 let VmValue::List(values) = params.get(key)? else {
985 return None;
986 };
987 values
988 .iter()
989 .map(|value| vm_string(value).map(ToString::to_string))
990 .collect()
991}
992
993fn optional_string_dict(
994 params: &BTreeMap<String, VmValue>,
995 key: &str,
996) -> Result<Option<BTreeMap<String, String>>, VmError> {
997 let Some(value) = params.get(key) else {
998 return Ok(None);
999 };
1000 let Some(dict) = value.as_dict() else {
1001 return Err(VmError::Runtime(format!(
1002 "host_call process.exec {key} must be a dict"
1003 )));
1004 };
1005 let mut out = BTreeMap::new();
1006 for (key, value) in dict.iter() {
1007 let Some(value) = vm_string(value) else {
1008 return Err(VmError::Runtime(format!(
1009 "host_call process.exec env value for {key:?} must be a string"
1010 )));
1011 };
1012 out.insert(key.clone(), value.to_string());
1013 }
1014 Ok(Some(out))
1015}
1016
1017fn vm_string(value: &VmValue) -> Option<&str> {
1018 match value {
1019 VmValue::String(value) => Some(value.as_ref()),
1020 _ => None,
1021 }
1022}
1023
1024pub(crate) fn register_host_builtins(vm: &mut Vm) {
1025 for def in MODULE_BUILTINS {
1026 vm.register_builtin_def(def);
1027 }
1028}
1029
1030#[harn_builtin(
1031 sig = "host_mock(capability: string, op: string, response_or_config?: any, params?: dict) -> nil",
1032 category = "host"
1033)]
1034fn host_mock_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1035 let host_mock = parse_host_mock(args)?;
1036 push_host_mock(host_mock);
1037 Ok(VmValue::Nil)
1038}
1039
1040#[harn_builtin(sig = "host_mock_clear() -> nil", category = "host")]
1041fn host_mock_clear_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1042 reset_host_state();
1043 Ok(VmValue::Nil)
1044}
1045
1046#[harn_builtin(sig = "host_mock_calls() -> list", category = "host")]
1047fn host_mock_calls_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1048 let calls = HOST_MOCK_CALLS.with(|calls| {
1049 calls
1050 .borrow()
1051 .iter()
1052 .map(mock_call_value)
1053 .collect::<Vec<_>>()
1054 });
1055 Ok(VmValue::List(std::sync::Arc::new(calls)))
1056}
1057
1058#[harn_builtin(sig = "host_mock_push_scope() -> nil", category = "host")]
1059fn host_mock_push_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1060 push_host_mock_scope();
1061 Ok(VmValue::Nil)
1062}
1063
1064#[harn_builtin(sig = "host_mock_pop_scope() -> nil", category = "host")]
1065fn host_mock_pop_scope_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1066 if !pop_host_mock_scope() {
1067 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1068 "host_mock_pop_scope: no scope to pop",
1069 ))));
1070 }
1071 Ok(VmValue::Nil)
1072}
1073
1074#[harn_builtin(sig = "host_capabilities() -> dict", category = "host")]
1075fn host_capabilities_builtin(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1076 Ok(capability_manifest_with_mocks())
1077}
1078
1079#[harn_builtin(
1080 sig = "host_has(capability: string, op?: string) -> bool",
1081 category = "host"
1082)]
1083fn host_has_builtin(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1084 let capability = args.first().map(|a| a.display()).unwrap_or_default();
1085 let operation = args.get(1).map(|a| a.display());
1086 let manifest = capability_manifest_with_mocks();
1087 let has = manifest
1088 .as_dict()
1089 .and_then(|d| d.get(&capability))
1090 .and_then(|v| v.as_dict())
1091 .is_some_and(|cap| {
1092 if let Some(operation) = operation {
1093 cap.get("ops")
1094 .and_then(|v| match v {
1095 VmValue::List(list) => {
1096 Some(list.iter().any(|item| item.display() == operation))
1097 }
1098 _ => None,
1099 })
1100 .unwrap_or(false)
1101 } else {
1102 true
1103 }
1104 });
1105 Ok(VmValue::Bool(has))
1106}
1107
1108#[harn_builtin(
1109 sig = "host_call(name: string, args?: dict) -> any",
1110 kind = "async",
1111 category = "host"
1112)]
1113async fn host_call_builtin(
1114 ctx: crate::vm::AsyncBuiltinCtx,
1115 args: Vec<VmValue>,
1116) -> Result<VmValue, VmError> {
1117 let name = args.first().map(|a| a.display()).unwrap_or_default();
1118 let params = args
1119 .get(1)
1120 .and_then(|a| a.as_dict())
1121 .cloned()
1122 .unwrap_or_default();
1123 let Some((capability, operation)) = name.split_once('.') else {
1124 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1125 format!("host_call: unsupported operation name '{name}'"),
1126 ))));
1127 };
1128 dispatch_host_operation_with_ctx(Some(&ctx), capability, operation, ¶ms).await
1129}
1130
1131#[harn_builtin(sig = "host_tool_list() -> list", kind = "async", category = "host")]
1132async fn host_tool_list_builtin(
1133 ctx: crate::vm::AsyncBuiltinCtx,
1134 _args: Vec<VmValue>,
1135) -> Result<VmValue, VmError> {
1136 dispatch_host_tool_list_with_ctx(Some(&ctx)).await
1137}
1138
1139#[harn_builtin(
1140 sig = "host_tool_call(name: string, args?: any) -> any",
1141 kind = "async",
1142 category = "host"
1143)]
1144async fn host_tool_call_builtin(
1145 ctx: crate::vm::AsyncBuiltinCtx,
1146 args: Vec<VmValue>,
1147) -> Result<VmValue, VmError> {
1148 let name = args.first().map(|a| a.display()).unwrap_or_default();
1149 if name.is_empty() {
1150 return Err(VmError::Thrown(VmValue::String(std::sync::Arc::from(
1151 "host_tool_call: tool name is required",
1152 ))));
1153 }
1154 let call_args = args.get(1).cloned().unwrap_or(VmValue::Nil);
1155 dispatch_host_tool_call_with_ctx(Some(&ctx), &name, &call_args).await
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::{
1161 capability_manifest_with_mocks, clear_host_call_bridge, dispatch_host_operation,
1162 dispatch_host_tool_call, dispatch_host_tool_list, dispatch_mock_host_call, push_host_mock,
1163 reset_host_state, resolve_process_exec_cwd, set_host_call_bridge, HostCallBridge, HostMock,
1164 };
1165 use std::collections::BTreeMap;
1166 use std::sync::{
1167 atomic::{AtomicUsize, Ordering},
1168 Arc,
1169 };
1170
1171 use crate::value::{VmError, VmValue};
1172
1173 #[test]
1174 fn process_exec_relative_cwd_resolves_against_execution_root() {
1175 let dir = tempfile::tempdir().expect("tempdir");
1176 crate::stdlib::process::set_thread_execution_context(Some(
1177 crate::orchestration::RunExecutionRecord {
1178 cwd: Some(dir.path().to_string_lossy().into_owned()),
1179 source_dir: Some(dir.path().join("src").to_string_lossy().into_owned()),
1180 env: BTreeMap::new(),
1181 adapter: None,
1182 repo_path: None,
1183 worktree_path: None,
1184 branch: None,
1185 base_ref: None,
1186 cleanup: None,
1187 },
1188 ));
1189
1190 assert_eq!(
1191 resolve_process_exec_cwd("subdir"),
1192 dir.path().join("subdir")
1193 );
1194
1195 crate::stdlib::process::set_thread_execution_context(None);
1196 }
1197
1198 #[test]
1199 fn manifest_includes_operation_metadata() {
1200 let manifest = capability_manifest_with_mocks();
1201 let process = manifest
1202 .as_dict()
1203 .and_then(|d| d.get("process"))
1204 .and_then(|v| v.as_dict())
1205 .expect("process capability");
1206 assert!(process.get("description").is_some());
1207 let operations = process
1208 .get("operations")
1209 .and_then(|v| v.as_dict())
1210 .expect("operations dict");
1211 assert!(operations.get("exec").is_some());
1212 }
1213
1214 #[test]
1215 fn mocked_capabilities_appear_in_manifest() {
1216 reset_host_state();
1217 push_host_mock(HostMock {
1218 capability: "project".to_string(),
1219 operation: "metadata_get".to_string(),
1220 params: None,
1221 result: Some(VmValue::Dict(std::sync::Arc::new(BTreeMap::new()))),
1222 error: None,
1223 });
1224 let manifest = capability_manifest_with_mocks();
1225 let project = manifest
1226 .as_dict()
1227 .and_then(|d| d.get("project"))
1228 .and_then(|v| v.as_dict())
1229 .expect("project capability");
1230 let operations = project
1231 .get("operations")
1232 .and_then(|v| v.as_dict())
1233 .expect("operations dict");
1234 assert!(operations.get("metadata_get").is_some());
1235 reset_host_state();
1236 }
1237
1238 #[test]
1239 fn mock_host_call_matches_partial_params_and_overrides_order() {
1240 reset_host_state();
1241 let mut exact_params = BTreeMap::new();
1242 exact_params.insert(
1243 "namespace".to_string(),
1244 VmValue::String(std::sync::Arc::from("facts")),
1245 );
1246 push_host_mock(HostMock {
1247 capability: "project".to_string(),
1248 operation: "metadata_get".to_string(),
1249 params: None,
1250 result: Some(VmValue::String(std::sync::Arc::from("fallback"))),
1251 error: None,
1252 });
1253 push_host_mock(HostMock {
1254 capability: "project".to_string(),
1255 operation: "metadata_get".to_string(),
1256 params: Some(exact_params),
1257 result: Some(VmValue::String(std::sync::Arc::from("facts"))),
1258 error: None,
1259 });
1260
1261 let mut call_params = BTreeMap::new();
1262 call_params.insert(
1263 "dir".to_string(),
1264 VmValue::String(std::sync::Arc::from("pkg")),
1265 );
1266 call_params.insert(
1267 "namespace".to_string(),
1268 VmValue::String(std::sync::Arc::from("facts")),
1269 );
1270 let exact = dispatch_mock_host_call("project", "metadata_get", &call_params)
1271 .expect("expected exact mock")
1272 .expect("exact mock should succeed");
1273 assert_eq!(exact.display(), "facts");
1274
1275 call_params.insert(
1276 "namespace".to_string(),
1277 VmValue::String(std::sync::Arc::from("classification")),
1278 );
1279 let fallback = dispatch_mock_host_call("project", "metadata_get", &call_params)
1280 .expect("expected fallback mock")
1281 .expect("fallback mock should succeed");
1282 assert_eq!(fallback.display(), "fallback");
1283 reset_host_state();
1284 }
1285
1286 #[test]
1287 fn mock_host_call_can_throw_errors() {
1288 reset_host_state();
1289 push_host_mock(HostMock {
1290 capability: "project".to_string(),
1291 operation: "metadata_get".to_string(),
1292 params: None,
1293 result: None,
1294 error: Some("boom".to_string()),
1295 });
1296 let params = BTreeMap::new();
1297 let result = dispatch_mock_host_call("project", "metadata_get", ¶ms)
1298 .expect("expected mock result");
1299 match result {
1300 Err(VmError::Thrown(VmValue::String(message))) => assert_eq!(message.as_ref(), "boom"),
1301 other => panic!("unexpected result: {other:?}"),
1302 }
1303 reset_host_state();
1304 }
1305
1306 #[derive(Default)]
1307 struct TestHostToolBridge;
1308
1309 impl HostCallBridge for TestHostToolBridge {
1310 fn dispatch(
1311 &self,
1312 _capability: &str,
1313 _operation: &str,
1314 _params: &BTreeMap<String, VmValue>,
1315 ) -> Result<Option<VmValue>, VmError> {
1316 Ok(None)
1317 }
1318
1319 fn list_tools(&self) -> Result<Option<VmValue>, VmError> {
1320 let tool = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
1321 (
1322 "name".to_string(),
1323 VmValue::String(std::sync::Arc::from("Read".to_string())),
1324 ),
1325 (
1326 "description".to_string(),
1327 VmValue::String(std::sync::Arc::from(
1328 "Read a file from the host".to_string(),
1329 )),
1330 ),
1331 (
1332 "schema".to_string(),
1333 VmValue::Dict(std::sync::Arc::new(BTreeMap::from([(
1334 "type".to_string(),
1335 VmValue::String(std::sync::Arc::from("object".to_string())),
1336 )]))),
1337 ),
1338 ("deprecated".to_string(), VmValue::Bool(false)),
1339 ])));
1340 Ok(Some(VmValue::List(std::sync::Arc::new(vec![tool]))))
1341 }
1342
1343 fn call_tool(&self, name: &str, args: &VmValue) -> Result<Option<VmValue>, VmError> {
1344 if name != "Read" {
1345 return Ok(None);
1346 }
1347 let path = args
1348 .as_dict()
1349 .and_then(|dict| dict.get("path"))
1350 .map(|value| value.display())
1351 .unwrap_or_default();
1352 Ok(Some(VmValue::String(std::sync::Arc::from(format!(
1353 "read:{path}"
1354 )))))
1355 }
1356 }
1357
1358 struct CountingProcessExecBridge {
1359 calls: Arc<AtomicUsize>,
1360 }
1361
1362 impl HostCallBridge for CountingProcessExecBridge {
1363 fn dispatch(
1364 &self,
1365 capability: &str,
1366 operation: &str,
1367 _params: &BTreeMap<String, VmValue>,
1368 ) -> Result<Option<VmValue>, VmError> {
1369 if (capability, operation) != ("process", "exec") {
1370 return Ok(None);
1371 }
1372 self.calls.fetch_add(1, Ordering::SeqCst);
1373 Ok(Some(VmValue::Dict(std::sync::Arc::new(BTreeMap::from([
1374 (
1375 "status".to_string(),
1376 VmValue::String(std::sync::Arc::from("completed".to_string())),
1377 ),
1378 ("exit_code".to_string(), VmValue::Int(0)),
1379 ("success".to_string(), VmValue::Bool(true)),
1380 ])))))
1381 }
1382 }
1383
1384 fn run_host_async_test<F, Fut>(test: F)
1385 where
1386 F: FnOnce() -> Fut,
1387 Fut: std::future::Future<Output = ()>,
1388 {
1389 let rt = tokio::runtime::Builder::new_current_thread()
1390 .enable_all()
1391 .build()
1392 .expect("runtime");
1393 rt.block_on(async {
1394 let local = tokio::task::LocalSet::new();
1395 local.run_until(test()).await;
1396 });
1397 }
1398
1399 #[test]
1400 fn host_tool_list_uses_installed_host_call_bridge() {
1401 run_host_async_test(|| async {
1402 reset_host_state();
1403 set_host_call_bridge(Arc::new(TestHostToolBridge));
1404 let tools = dispatch_host_tool_list().await.expect("tool list");
1405 clear_host_call_bridge();
1406
1407 let VmValue::List(items) = tools else {
1408 panic!("expected tool list");
1409 };
1410 assert_eq!(items.len(), 1);
1411 let tool = items[0].as_dict().expect("tool dict");
1412 assert_eq!(tool.get("name").unwrap().display(), "Read");
1413 assert_eq!(tool.get("deprecated").unwrap().display(), "false");
1414 });
1415 }
1416
1417 #[test]
1418 fn host_tool_call_uses_installed_host_call_bridge() {
1419 run_host_async_test(|| async {
1420 set_host_call_bridge(Arc::new(TestHostToolBridge));
1421 let args = VmValue::Dict(std::sync::Arc::new(BTreeMap::from([(
1422 "path".to_string(),
1423 VmValue::String(std::sync::Arc::from("README.md".to_string())),
1424 )])));
1425 let value = dispatch_host_tool_call("Read", &args)
1426 .await
1427 .expect("tool call");
1428 clear_host_call_bridge();
1429 assert_eq!(value.display(), "read:README.md");
1430 });
1431 }
1432
1433 #[test]
1434 fn process_exec_bridge_is_gated_by_command_policy() {
1435 run_host_async_test(|| async {
1436 crate::orchestration::clear_command_policies();
1437 let calls = Arc::new(AtomicUsize::new(0));
1438 set_host_call_bridge(Arc::new(CountingProcessExecBridge {
1439 calls: calls.clone(),
1440 }));
1441 crate::orchestration::push_command_policy(crate::orchestration::CommandPolicy {
1442 tools: vec!["run".to_string()],
1443 workspace_roots: Vec::new(),
1444 default_shell_mode: "shell".to_string(),
1445 deny_patterns: vec!["cat *".to_string()],
1446 require_approval: Default::default(),
1447 pre: None,
1448 post: None,
1449 allow_recursive: false,
1450 });
1451
1452 let result = dispatch_host_operation(
1453 "process",
1454 "exec",
1455 &BTreeMap::from([
1456 (
1457 "mode".to_string(),
1458 VmValue::String(std::sync::Arc::from("shell")),
1459 ),
1460 (
1461 "command".to_string(),
1462 VmValue::String(std::sync::Arc::from("cat Cargo.toml")),
1463 ),
1464 ]),
1465 )
1466 .await
1467 .expect("process.exec result");
1468
1469 crate::orchestration::clear_command_policies();
1470 clear_host_call_bridge();
1471
1472 assert_eq!(
1473 calls.load(Ordering::SeqCst),
1474 0,
1475 "blocked command must not reach host bridge"
1476 );
1477 let result = result.as_dict().expect("blocked result dict");
1478 assert_eq!(result.get("status").unwrap().display(), "blocked");
1479 assert!(
1480 result
1481 .get("reason")
1482 .map(VmValue::display)
1483 .unwrap_or_default()
1484 .contains("cat *"),
1485 "blocked result should name the matched policy pattern"
1486 );
1487 });
1488 }
1489
1490 #[test]
1491 fn host_tool_list_is_empty_without_bridge() {
1492 run_host_async_test(|| async {
1493 clear_host_call_bridge();
1494 let tools = dispatch_host_tool_list().await.expect("tool list");
1495 let VmValue::List(items) = tools else {
1496 panic!("expected tool list");
1497 };
1498 assert!(items.is_empty());
1499 });
1500 }
1501}