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