Skip to main content

harn_hostlib/
error.rs

1//! Error type for hostlib host calls.
2//!
3//! Builtins translate this into VM-level errors via [`Into<harn_vm::VmError>`]
4//! so that Harn scripts see structured exceptions rather than panics.
5
6use harn_vm::VmDictExt;
7
8use harn_vm::{VmError, VmValue};
9
10/// All errors a hostlib builtin can surface.
11///
12/// Variants intentionally describe the *kind* of failure rather than the
13/// specific module — every module routes its missing-implementation errors
14/// through [`HostlibError::Unimplemented`] so embedders and tests can
15/// distinguish intentionally scaffolded contracts from runtime failures.
16#[derive(Debug, thiserror::Error)]
17pub enum HostlibError {
18    /// The method exists in the registration table but has no implementation
19    /// yet. This is the canonical scaffold-stage error: it tells callers
20    /// "the contract is stable, but this module has not been implemented."
21    #[error(
22        "hostlib: {builtin} is not implemented yet (scaffolded contract without an implementation)"
23    )]
24    Unimplemented {
25        /// Fully-qualified builtin name, e.g. `"hostlib_ast_parse_file"`.
26        builtin: &'static str,
27    },
28
29    /// A required parameter was missing from the call payload.
30    #[error("hostlib: {builtin}: missing required parameter '{param}'")]
31    MissingParameter {
32        /// Fully-qualified builtin name.
33        builtin: &'static str,
34        /// Name of the missing parameter.
35        param: &'static str,
36    },
37
38    /// A parameter was present but had the wrong shape (wrong type, malformed).
39    #[error("hostlib: {builtin}: invalid parameter '{param}': {message}")]
40    InvalidParameter {
41        /// Fully-qualified builtin name.
42        builtin: &'static str,
43        /// Name of the invalid parameter.
44        param: &'static str,
45        /// Human-readable description of the violation.
46        message: String,
47    },
48
49    /// Catch-all wrapper for I/O, parsing, or other backend failures.
50    #[error("hostlib: {builtin}: {message}")]
51    Backend {
52        /// Fully-qualified builtin name.
53        builtin: &'static str,
54        /// Human-readable failure description.
55        message: String,
56    },
57
58    /// The OS could not start a requested process. `kind` is the canonical
59    /// `io::ErrorKind` spelling retained for script-side classification.
60    #[error("hostlib: {builtin}: process spawn failed: {message}")]
61    ProcessSpawn {
62        /// Fully-qualified builtin name.
63        builtin: &'static str,
64        /// Stable kind such as `not_found` or `permission_denied`.
65        kind: &'static str,
66        /// Human-readable OS error.
67        message: String,
68        /// Canonical caller-selected directory, absent when the command
69        /// inherited its directory from the active execution context.
70        requested_cwd: Option<String>,
71        /// Canonical directory in which the spawn was attempted.
72        cwd: String,
73    },
74
75    /// A path the builtin resolved fell outside the session's workspace
76    /// roots under a restricted sandbox profile. The mirror of the
77    /// `harness.fs.*` `tool_rejected` rejection — both surfaces reject an
78    /// out-of-root path with the same message.
79    #[error("{message}")]
80    SandboxViolation {
81        /// Fully-qualified builtin name.
82        builtin: &'static str,
83        /// The normalized path that was rejected, for telemetry.
84        path: String,
85        /// The canonical rejection message (see
86        /// [`harn_vm::process_sandbox::SandboxViolation::message`]).
87        message: String,
88    },
89
90    /// A host capability cannot preserve the active sandbox contract.
91    #[error("{message}")]
92    SandboxUnsupported {
93        /// Fully-qualified builtin name.
94        builtin: &'static str,
95        /// Active sandbox profile.
96        profile: String,
97        /// Stable rejection message.
98        message: String,
99    },
100
101    /// A never-approvable UNIVERSAL catastrophic command (machine/disk/data
102    /// destruction) was rejected by the floor BEFORE spawning, at the shared
103    /// [`crate::process::spawn_process`] chokepoint. Enforced unconditionally
104    /// (no `command_policy` required), so it is universal across every hostlib
105    /// process tool, embedders, and standalone Harn. Mirrors the
106    /// `catastrophic_floor` disposition the `process.exec` command-policy
107    /// preflight surfaces. See
108    /// [`harn_vm::orchestration::universal_catastrophic_reason`].
109    #[error("{message}")]
110    CatastrophicFloor {
111        /// Fully-qualified builtin name.
112        builtin: &'static str,
113        /// The verbatim floor rationale (never-approvable reason).
114        message: String,
115    },
116}
117
118impl HostlibError {
119    /// The fully-qualified builtin name this error came from. Useful for
120    /// embedder logging and for the routing tests in `tests/`.
121    pub fn builtin(&self) -> &'static str {
122        match self {
123            HostlibError::Unimplemented { builtin }
124            | HostlibError::MissingParameter { builtin, .. }
125            | HostlibError::InvalidParameter { builtin, .. }
126            | HostlibError::Backend { builtin, .. }
127            | HostlibError::ProcessSpawn { builtin, .. }
128            | HostlibError::SandboxViolation { builtin, .. }
129            | HostlibError::SandboxUnsupported { builtin, .. }
130            | HostlibError::CatastrophicFloor { builtin, .. } => builtin,
131        }
132    }
133}
134
135impl From<HostlibError> for VmError {
136    fn from(err: HostlibError) -> VmError {
137        // Surface as a `Thrown` dict so Harn `try`/`catch` can pattern-match
138        // on `kind`, `builtin`, and `message`. This matches how the existing
139        // `host_call` error path shapes its exceptions.
140        let kind = match &err {
141            HostlibError::Unimplemented { .. } => "unimplemented",
142            HostlibError::MissingParameter { .. } => "missing_parameter",
143            HostlibError::InvalidParameter { .. } => "invalid_parameter",
144            HostlibError::Backend { .. } => "backend_error",
145            HostlibError::ProcessSpawn { kind, .. } => *kind,
146            HostlibError::SandboxViolation { .. } => "tool_rejected",
147            HostlibError::SandboxUnsupported { .. } => "sandbox_unsupported",
148            HostlibError::CatastrophicFloor { .. } => "catastrophic_floor",
149        };
150        // Carry the offending path on sandbox violations so `catch` blocks
151        // and telemetry can branch on it without re-parsing the message.
152        let path = match &err {
153            HostlibError::SandboxViolation { path, .. } => Some(path.clone()),
154            _ => None,
155        };
156        let profile = match &err {
157            HostlibError::SandboxUnsupported { profile, .. } => Some(profile.clone()),
158            _ => None,
159        };
160        let builtin = err.builtin();
161        let is_process_spawn = matches!(&err, HostlibError::ProcessSpawn { .. });
162        let process_cwd = match &err {
163            HostlibError::ProcessSpawn {
164                requested_cwd, cwd, ..
165            } => Some((requested_cwd.clone(), cwd.clone())),
166            _ => None,
167        };
168        let message = err.to_string();
169
170        let mut dict: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
171        dict.put_str("kind", kind);
172        dict.put_str("builtin", builtin);
173        dict.put_str("message", message);
174        if is_process_spawn {
175            dict.put_str("error", "io_error");
176            dict.put_str("operation", "process_spawn");
177            dict.put_str("category", "environment");
178        }
179        if let Some((requested_cwd, cwd)) = process_cwd {
180            match requested_cwd {
181                Some(requested_cwd) => dict.put_str("requested_cwd", requested_cwd),
182                None => {
183                    dict.insert(harn_vm::value::intern_key("requested_cwd"), VmValue::Nil);
184                }
185            }
186            dict.put_str("cwd", cwd);
187        }
188        if let Some(path) = path {
189            dict.put_str("path", path);
190        }
191        if let Some(profile) = profile {
192            dict.put_str("profile", profile);
193        }
194        VmError::Thrown(VmValue::dict(dict))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn process_spawn_error_lowers_to_typed_io_value() {
204        let error = HostlibError::ProcessSpawn {
205            builtin: "hostlib_tools_run_command",
206            kind: "not_found",
207            message: "No such file or directory".to_string(),
208            requested_cwd: Some("/workspace/project".to_string()),
209            cwd: "/workspace/project".to_string(),
210        };
211        let VmError::Thrown(VmValue::Dict(fields)) = VmError::from(error) else {
212            panic!("expected a structured thrown value");
213        };
214        let field = |name| fields.get(name).map(VmValue::display);
215        assert_eq!(field("error").as_deref(), Some("io_error"));
216        assert_eq!(field("kind").as_deref(), Some("not_found"));
217        assert_eq!(field("operation").as_deref(), Some("process_spawn"));
218        assert_eq!(field("category").as_deref(), Some("environment"));
219        assert_eq!(
220            field("requested_cwd").as_deref(),
221            Some("/workspace/project")
222        );
223        assert_eq!(field("cwd").as_deref(), Some("/workspace/project"));
224        assert_eq!(
225            field("builtin").as_deref(),
226            Some("hostlib_tools_run_command")
227        );
228    }
229}