Skip to main content

harn_vm/stdlib/
host.rs

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