Skip to main content

capsule_runtime/
errors.rs

1use std::path::PathBuf;
2
3use thiserror::Error;
4
5/// One `capabilities.shell.allow` entry that resolved to a script whose interpreter's package
6/// tree nothing declared could reach inside a `sealed` composed root.
7///
8/// Defined here rather than in [`crate::reachability`] — which is a `pub(crate)` module — because
9/// it is a field of a public [`RuntimeError`] variant and so has to be nameable by anything that
10/// matches on one (`murmur-cli`'s `CliError` conversion, above all).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UnreachableEntrypoint {
13    /// The `capabilities.shell.allow` entry, verbatim as the manifest wrote it.
14    pub binary: String,
15    /// Where that entry resolves on this host — the first `PATH` match, i.e. the one `execvp`
16    /// would run.
17    pub resolved_path: PathBuf,
18    /// The bare interpreter name read out of the file's `#!` line, with one level of
19    /// `env NAME` indirection resolved. This is the name a covering
20    /// `interpreter_runtime`/`staged_runtime` grant would have to declare.
21    pub interpreter: String,
22}
23
24impl std::fmt::Display for UnreachableEntrypoint {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(
27            f,
28            "'{}' ({}, a script run by '{}')",
29            self.binary,
30            self.resolved_path.display(),
31            self.interpreter
32        )
33    }
34}
35
36#[derive(Debug, Error)]
37pub enum RuntimeError {
38    #[error("artifact {name}@{version} not found in registry")]
39    ArtifactNotFound { name: String, version: String },
40
41    #[error("artifact integrity check failed for {name}@{version}")]
42    ArtifactIntegrityFailed { name: String, version: String },
43
44    #[error("lockfile missing artifact entry for '{name}'")]
45    LockMissingEntry { name: String },
46
47    #[error(
48        "lockfile version mismatch for '{name}': manifest requested {requested}, lock pinned {pinned}"
49    )]
50    LockVersionMismatch {
51        name: String,
52        requested: String,
53        pinned: String,
54    },
55
56    #[error("failed to read artifact archive for {name}@{version}: {message}")]
57    ArtifactArchive {
58        name: String,
59        version: String,
60        message: String,
61    },
62
63    #[error("failed to compile component for {name}@{version}: {message}")]
64    ToolComponentCompile {
65        name: String,
66        version: String,
67        message: String,
68    },
69
70    #[error("failed to compile capsule component: {0}")]
71    CapsuleCompile(String),
72
73    #[error("failed to create workdir at {path}: {source}")]
74    CreateWorkdir {
75        path: String,
76        #[source]
77        source: std::io::Error,
78    },
79
80    #[error("failed to write tool manifest at {path}: {source}")]
81    WriteToolManifest {
82        path: String,
83        #[source]
84        source: std::io::Error,
85    },
86
87    #[error("failed to read inference.system_prompt_file at {path}: {source}")]
88    SystemPromptFileRead {
89        path: String,
90        #[source]
91        source: std::io::Error,
92    },
93
94    #[error("failed to read inference.compaction.system_prompt_file at {path}: {source}")]
95    CompactionSystemPromptFileRead {
96        path: String,
97        #[source]
98        source: std::io::Error,
99    },
100
101    #[error(
102        "inference.system_prompt_artifact '{name}': skill.md not found or unreadable; \
103         ensure the skill is declared in artifacts: and the capsule was staged"
104    )]
105    SystemPromptArtifactRead {
106        name: String,
107        #[source]
108        source: std::io::Error,
109    },
110
111    #[error("skill source path not found: {path}")]
112    SkillSourceNotFound { path: String },
113
114    #[error("skill source directory '{path}' contains no skill.md")]
115    SkillSourceMissingSkillMd { path: String },
116
117    #[error("failed to read skill source at {path}: {source}")]
118    SkillSourceRead {
119        path: String,
120        #[source]
121        source: std::io::Error,
122    },
123
124    #[error("invalid network capability allow entry '{entry}': {message}")]
125    InvalidNetworkAllowEntry { entry: String, message: String },
126
127    #[error("invalid filesystem capability scope '{scope}': {message}")]
128    InvalidFilesystemScope { scope: String, message: String },
129
130    /// The declared containment floor is stronger than what this host's kernel can back.
131    /// Raised in the stage/launch seam, before any registry pull, component compile or workdir
132    /// creation — `achieved` always comes from a live host probe, never from the manifest.
133    #[error(
134        "declared containment class '{declared}' is not achievable on this host (achieved: '{achieved}'): {reason}"
135    )]
136    ContainmentFloorUnmet {
137        declared: murmur_artifact::ContainmentClass,
138        achieved: murmur_artifact::ContainmentClass,
139        reason: String,
140    },
141
142    /// A capsule declares `capabilities.shell.staged_runtime` without an effective `sealed`
143    /// containment floor.
144    ///
145    /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one compares the declared
146    /// floor against what the *host* can back, and its remedy is to lower the floor or move hosts.
147    /// This one is decided against the declared floor alone and never looks at the host at all, so
148    /// it fires identically on a machine that could deliver `sealed` — a staged runtime has no
149    /// composed root to be staged into unless the capsule asked for one, and quietly launching it
150    /// without the mount would leave the interpreter simply absent. Its remedy is to raise the
151    /// declared floor (or drop the grant), which is the opposite advice.
152    ///
153    /// `binaries` names every offending `staged_runtime` binary so the operator does not have to
154    /// re-run to find the second one.
155    #[error(
156        "capabilities.shell.staged_runtime is declared for {} but the effective containment floor \
157         is '{declared}' — staging a runtime tree requires the 'sealed' floor, because there is no \
158         composed root to bind-mount it into below that",
159        .binaries.join(", ")
160    )]
161    StagedRuntimeRequiresSealed {
162        binaries: Vec<String>,
163        declared: murmur_artifact::ContainmentClass,
164    },
165
166    /// A `sealed` capsule allowlists a script whose interpreter's package tree nothing declared
167    /// could make reachable inside the composed root.
168    ///
169    /// Deliberately distinct from [`Self::StagedRuntimeRequiresSealed`], which it sits next to in
170    /// `stage_session`: that one fires when a grant *was* declared at too low a floor, this one
171    /// fires when no grant was declared at all and the floor is already `sealed`. Their remedies
172    /// point in opposite directions — raise the floor there, add a grant here.
173    ///
174    /// `entries` names every offending allowlist entry so an operator fixing this does not have to
175    /// re-run to discover the second script, following the same precedent.
176    #[error(
177        "capabilities.shell.allow grants {} under the 'sealed' containment floor, but nothing \
178         declared makes the interpreted entrypoint's own package tree reachable inside the \
179         composed root — the script's ELF/DT_NEEDED closure is empty, so staging it stages \
180         nothing its interpreter imports, and the capsule would fail with a module-not-found \
181         error partway into a run rather than here. Declare \
182         capabilities.shell.interpreter_runtime or capabilities.shell.staged_runtime naming the \
183         interpreter (measure the real directories with \
184         `strace -f -e trace=openat,getdents64 <the command>`), or use a copy of the interpreter \
185         and its packages that already lives under a fixed sealed runtime path",
186        .entries
187            .iter()
188            .map(UnreachableEntrypoint::to_string)
189            .collect::<Vec<_>>()
190            .join("; ")
191    )]
192    ShellBinaryPackageUnreachable { entries: Vec<UnreachableEntrypoint> },
193
194    /// A `sealed` session's composed root could not be built in the forked child, *after* the
195    /// pre-launch probe reported the mechanism available.
196    ///
197    /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one is a refusal decided
198    /// before anything is staged, on a host that never claimed to offer `sealed`. This one is a
199    /// host that claimed it and then failed to deliver — a race, a mount table that changed under
200    /// the runtime, or an edge case the cheap probe cannot catch. Conflating the two would tell an
201    /// operator to lower their declared floor when the real answer is that something moved.
202    ///
203    /// `detail` is the message the child wrote to the `pre_exec` diagnostic pipe, which names the
204    /// exact step (`unshare`, a specific bind mount, `pivot_root`) and its errno.
205    #[error(
206        "the sealed containment class was achievable at launch but its composed root could not be \
207         built for this subprocess: {detail}"
208    )]
209    SealedRootConstructionFailed { detail: String },
210
211    /// This host cannot give a capsule's native subprocess tree its own network namespace, so the
212    /// egress boundary that mediates `capabilities.network.allow` cannot be built.
213    ///
214    /// Raised before any registry pull, component compile or workdir creation, and raised for
215    /// **every** Linux capsule that can spawn a subprocess — including one whose
216    /// `capabilities.network.allow` is empty. That breadth is the point: with the namespace
217    /// missing, an empty allowlist would mean *unrestricted* egress rather than none, because the
218    /// seccomp `connect`/`sendto` interception that used to back it was deleted as unsound rather
219    /// than kept as a fallback. There is deliberately no path that continues at reduced
220    /// enforcement.
221    ///
222    /// Deliberately distinct from [`Self::ContainmentFloorUnmet`], which this would otherwise
223    /// look like: that one compares a *declared* containment class against what the host can
224    /// back, and its remedy is to lower the declared floor. A network namespace is not part of
225    /// the containment ladder — every class needs it equally — so lowering the floor fixes
226    /// nothing here, and offering that advice would be actively misleading.
227    #[error(
228        "this host cannot give the capsule's subprocess tree its own network namespace, so \
229         capabilities.network.allow cannot be enforced for it: {reason}"
230    )]
231    EgressNamespaceUnavailable {
232        blocker: crate::network_namespace::EgressNamespaceBlocker,
233        reason: String,
234    },
235
236    #[error(
237        "capsule component missing export murmur:capsule/run@0.1.0#run; rebuild the artifact against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
238    )]
239    CapsuleExportMissing,
240
241    #[error(
242        "tool component '{name}' missing export murmur:tool/run@0.1.0#run; rebuild the artifact against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
243    )]
244    ToolExportMissing { name: String },
245
246    #[error("capsule execution trapped: {0}")]
247    CapsuleTrap(String),
248
249    #[error(
250        "capsule execution exceeded its {seconds}s deadline and was interrupted; \
251         raise capabilities.limits.deadline_seconds in murmur.yaml if the capsule \
252         legitimately needs longer"
253    )]
254    CapsuleDeadlineExceeded { seconds: u64 },
255
256    #[error("capsule execution exceeded its configured resource limits: {message}")]
257    CapsuleResourceLimit { message: String },
258
259    #[error(
260        "shell subprocess '{binary}' was killed for exceeding \
261         capabilities.resources.{limit}: {detail}"
262    )]
263    ShellResourceLimitExceeded {
264        binary: String,
265        limit: String,
266        detail: String,
267    },
268
269    #[error(
270        "this capsule can spawn native subprocesses but no cgroup v2 scope could be created to \
271         bound them ({reason}); on Linux the runtime refuses to launch rather than run a \
272         subprocess tree with no aggregate memory/pids/cpu ceiling — see the systemd user \
273         delegation requirement in docs/content/reference/resource-limits-manual-verification.md"
274    )]
275    CgroupDelegationUnavailable { reason: String },
276
277    #[error(
278        "session workdir grew to {observed_bytes} bytes, past the {max_bytes} byte ceiling \
279         (capabilities.resources.workdir_max_bytes)"
280    )]
281    WorkdirSizeExceeded { max_bytes: u64, observed_bytes: u64 },
282
283    #[error("failed to initialize WASI for {path}: {message}")]
284    WasiInit { path: String, message: String },
285
286    #[error("inference driver is not configured; add inference.driver.artifact to murmur.yaml")]
287    DriverNotConfigured,
288
289    #[error("inference driver '{0}' is not installed in the local tool registry")]
290    DriverNotInstalled(String),
291
292    #[error("agent loop failed: {0}")]
293    AgentLoopFailed(String),
294
295    #[error("internal_port {port} is already bound; choose another port or omit internal_port to use an OS-assigned port")]
296    PortInUse { port: u16 },
297
298    #[error("runtime failure: {0}")]
299    Runtime(String),
300}
301
302impl RuntimeError {
303    #[must_use]
304    pub fn artifact_not_found(name: impl Into<String>, version: impl Into<String>) -> Self {
305        Self::ArtifactNotFound {
306            name: name.into(),
307            version: version.into(),
308        }
309    }
310
311    #[must_use]
312    pub fn artifact_integrity_failed(name: impl Into<String>, version: impl Into<String>) -> Self {
313        Self::ArtifactIntegrityFailed {
314            name: name.into(),
315            version: version.into(),
316        }
317    }
318
319    #[must_use]
320    pub fn wasi(path: PathBuf, message: impl Into<String>) -> Self {
321        Self::WasiInit {
322            path: path.display().to_string(),
323            message: message.into(),
324        }
325    }
326}