Skip to main content

submilli_engine/stdlib/
shared.rs

1//! Helpers shared across stdlib libraries.
2
3use crate::runtime::decision::{
4    CallOrigin, CallSite, CallTicket, EntryPath, PackageLine, SourceLine,
5};
6use crate::runtime::fs::{ContainError, ContentPath, LinkPath, resolve_content, resolve_link};
7use crate::runtime::fuel;
8use crate::runtime::host::{
9    permission_denied, permission_denied_invariant, permission_denied_read_only,
10    quota_exceeded_error, write_boxed_number_struct,
11};
12use crate::runtime::security::{AuditDecision, CheckOutcome, SecurityCheck};
13use crate::runtime::vfs::{Access, Placement};
14use crate::runtime::{QuotaExceeded, StoreData};
15
16/// The package whose code is executing, read off the innermost wasm frame's owning module.
17///
18/// This answers "whose code is running", which is the question a capability check needs —
19/// not "whose export did we last enter", which is what a shadow stack records. The two
20/// diverge whenever a package invokes caller-supplied code: a vtable-dispatched `toJson`, a
21/// closure passed across a package boundary.
22///
23/// Fails closed on both ways the principal can come back unknown, and neither may fall back
24/// to a permissive default:
25///
26/// - **No frames.** No wasm is executing, so this is an embedder-driven call with no guest
27///   principal to speak of.
28/// - **Frames, but the innermost module has no name.** A module this compiler produced always
29///   carries one. One that does not is not a principal we can name.
30///
31/// Attribution visits the innermost module directly, independently of the
32/// diagnostic backtrace setting, without capturing or symbolizing outer frames.
33pub(crate) fn running_package(
34    store: &impl wasmtime::AsContext<Data = StoreData>,
35) -> Result<String, PrincipalError> {
36    let mut principal = None;
37    wasmtime::WasmBacktrace::visit_modules(store, |module| {
38        principal = Some(match module.name() {
39            Some(name) => owned_principal(name).map_err(PrincipalError::Internal),
40            None => Err(PrincipalError::Unknown(UnknownPrincipal {
41                label: "<unnamed module>",
42                reason: "the running module declares no package name, so its caller cannot be identified",
43            })),
44        });
45        std::ops::ControlFlow::Break(())
46    }).map_err(|error| PrincipalError::Internal(crate::runtime::host::fatal_host_error(error)))?;
47    principal.unwrap_or_else(|| {
48        Err(PrincipalError::Unknown(UnknownPrincipal {
49            label: "<no wasm frame>",
50            reason: "no wasm frame is executing, so the call has no caller to attribute it to",
51        }))
52    })
53}
54
55pub(crate) fn owned_principal(name: &str) -> wasmtime::Result<String> {
56    let mut result = String::new();
57    result
58        .try_reserve_exact(name.len())
59        .map_err(crate::runtime::host::fatal_host_error)?;
60    result.push_str(name);
61    Ok(result)
62}
63
64pub(crate) enum PrincipalError {
65    Unknown(UnknownPrincipal),
66    Internal(wasmtime::Error),
67}
68impl PrincipalError {
69    pub fn into_denial(self, capability: &str) -> wasmtime::Error {
70        match self {
71            Self::Unknown(unknown) => {
72                permission_denied_invariant(unknown.label, capability, unknown.reason)
73            }
74            Self::Internal(error) => error,
75        }
76    }
77
78    pub fn label_or_error(self) -> wasmtime::Result<String> {
79        match self {
80            Self::Unknown(unknown) => owned_principal(unknown.label),
81            Self::Internal(error) => Err(error),
82        }
83    }
84}
85
86/// An unknown principal label cannot collide with an actual package name.
87pub(crate) struct UnknownPrincipal {
88    pub label: &'static str,
89    pub reason: &'static str,
90}
91
92/// Call at the top of each gated host fn body — after argument parsing,
93/// before any I/O. Attributes the call to whoever's code is running, via
94/// [`running_package`]. A denial surfaces to the guest as a catchable
95/// `PermissionDeniedError`.
96///
97/// A capability the catalog marks `main_denial` is refused to `main` here,
98/// ahead of the policy. `submilli:security#check` does not route through this
99/// function, so the invariant does not extend to it; that path attributes to
100/// the caller and would deny anyway.
101pub fn check_security(
102    store: impl wasmtime::AsContextMut<Data = StoreData>,
103    capability: &str,
104    context: serde_json::Value,
105) -> wasmtime::Result<()> {
106    check_security_call(store, capability, context).map(|_| ())
107}
108
109/// [`check_security`], returning the recorder's ticket for the call when one is installed,
110/// for host functions whose later decisions (redirect hops) belong to the same call.
111pub(crate) fn check_security_call(
112    mut store: impl wasmtime::AsContextMut<Data = StoreData>,
113    capability: &str,
114    context: serde_json::Value,
115) -> wasmtime::Result<Option<CallTicket>> {
116    // Direct caller attribution and the policy check have one flat gate charge.
117    fuel::charge_host_fuel(&mut store, fuel::GATE)?;
118    let caller = running_package(&store).map_err(|error| {
119        if let PrincipalError::Unknown(ref unknown) = error {
120            audit_entry_denial(
121                &store,
122                unknown.label,
123                capability,
124                &context,
125                "invariant",
126                unknown.reason,
127            );
128        }
129        error.into_denial(capability)
130    })?;
131    // Uncharged on purpose; see `DecisionLogConfig::max_line_capture_frames`.
132    let ticket = begin_recorded_call(&store, &caller, capability);
133    authorize_capability(
134        &caller,
135        store.as_context().data().security_check.as_ref(),
136        capability,
137        &context,
138        store.as_context().data().vfs.cwd(),
139        CallSite::new(ticket, EntryPath::GatedOp),
140    )?;
141    Ok(ticket)
142}
143
144/// Begins the recorder's call for a host function at its entry, with where in the
145/// submitted program, and in a package, it came from. `None` when no recorder is installed.
146pub(crate) fn begin_recorded_call(
147    store: &impl wasmtime::AsContext<Data = StoreData>,
148    caller: &str,
149    capability: &str,
150) -> Option<CallTicket> {
151    let recorder = store.as_context().data().security_check.recorder()?;
152    Some(recorder.begin_call(caller, capability, call_origin(store)))
153}
154
155/// Where the running call came from: the line in the submitted program, and the line
156/// inside a package when the program reached the call through one. Each is `None` when
157/// its lookup fails.
158///
159/// Walks a full backtrace, which costs more than [`running_package`]'s module visit, so
160/// it runs only while a recorder is installed and still wants lines, uncharged to guest
161/// fuel (see `DecisionLogConfig::max_line_capture_frames`). The program's line is its own
162/// call into the package; the package's line is the innermost frame of package code.
163pub(crate) fn call_origin(store: &impl wasmtime::AsContext<Data = StoreData>) -> CallOrigin {
164    let Some(recorder) = store.as_context().data().security_check.recorder() else {
165        return CallOrigin::default();
166    };
167    if !recorder.wants_line() {
168        return CallOrigin::default();
169    }
170    let backtrace = wasmtime::WasmBacktrace::force_capture(store);
171    recorder.note_line_capture(backtrace.frames().len());
172    let frames = backtrace.frames();
173    let program = frames
174        .iter()
175        .position(|frame| frame.module().name() == Some(crate::mangle::USER_PACKAGE));
176    let line = program.and_then(|at| {
177        let symbol = frames[at].symbols().first()?;
178        Some(SourceLine {
179            line: symbol.line()?,
180            column: symbol.column(),
181        })
182    });
183    // Frames inside the program's call: a package's frames carry its name on their path.
184    let package = frames[..program.unwrap_or(frames.len())]
185        .iter()
186        .find_map(package_line);
187    CallOrigin { line, package }
188}
189
190/// The package line a frame is at, when it is package code with a known line.
191fn package_line(frame: &wasmtime::FrameInfo) -> Option<PackageLine> {
192    let package = frame.module().name()?;
193    let symbol = frame.symbols().first()?;
194    let module = symbol.file()?.strip_prefix(package)?.strip_prefix('/')?;
195    Some(PackageLine {
196        package: package.to_owned(),
197        module: module.to_owned(),
198        function: symbol.name().map(ToString::to_string),
199        line: symbol.line()?,
200        column: symbol.column(),
201    })
202}
203
204/// Marks the latest recorded decision as a denial this host function swallowed to filter a
205/// listing. A no-op without a recorder.
206pub(crate) fn mark_filtered(store: &impl wasmtime::AsContext<Data = StoreData>) {
207    if let Some(recorder) = store.as_context().data().security_check.recorder() {
208        recorder.mark_last_filtered();
209    }
210}
211
212/// [`check_security`] for a caller resolved earlier, such as the principal of a
213/// request whose redirect hops are checked after the host fn has suspended.
214pub(crate) fn authorize_capability(
215    caller: &str,
216    security_check: &dyn SecurityCheck,
217    capability: &str,
218    context: &serde_json::Value,
219    cwd: &str,
220    site: CallSite,
221) -> wasmtime::Result<()> {
222    // The ordering is the invariant. This must precede both the delegation
223    // below and any work the host fn does after we return — a reorder that
224    // consults the policy first makes the refusal conditional on a policy
225    // existing, and the default check allows everything.
226    if caller == crate::mangle::USER_PACKAGE
227        && let Some(reason) =
228            crate::stdlib::capabilities::find_any(capability).and_then(|entry| entry.main_denial)
229    {
230        audit_denial_at(
231            security_check,
232            caller,
233            capability,
234            context,
235            "invariant",
236            reason,
237            site,
238        );
239        return Err(permission_denied_invariant(caller, capability, reason));
240    }
241    check_and_audit(security_check, caller, capability, context, cwd, site)
242        .map_err(|reason| permission_denied(caller, capability, reason))
243}
244
245/// Asks the policy whether `caller` may use `capability`, and audits its answer with the
246/// recorder's explanation. A denial returns the policy's reason.
247pub(crate) fn check_and_audit(
248    security_check: &dyn SecurityCheck,
249    caller: &str,
250    capability: &str,
251    context: &serde_json::Value,
252    cwd: &str,
253    site: CallSite,
254) -> Result<(), String> {
255    let outcome = security_check.check_with_cwd(caller, capability, context, cwd);
256    // The explanation is for the recorder alone; enforcement never reads it.
257    let explanation = security_check
258        .recorder()
259        .and_then(|_| security_check.explain(caller, capability, context, cwd));
260    let audit_context = security_check.audit_context(capability, context, cwd);
261    let context = audit_context.as_ref();
262    match outcome {
263        CheckOutcome::Allow { rule } => {
264            security_check.audit(
265                AuditDecision::new(caller, capability, context, true, "policy", rule, None)
266                    .with_explanation(explanation.as_ref())
267                    .with_site(site),
268            );
269            Ok(())
270        }
271        CheckOutcome::Deny { reason, rule } => {
272            security_check.audit(
273                AuditDecision::new(
274                    caller,
275                    capability,
276                    context,
277                    false,
278                    "policy",
279                    rule,
280                    Some(&reason),
281                )
282                .with_explanation(explanation.as_ref())
283                .with_site(site),
284            );
285            Err(reason)
286        }
287    }
288}
289
290pub(crate) fn audit_denial_at(
291    security: &dyn SecurityCheck,
292    caller: &str,
293    capability: &str,
294    context: &serde_json::Value,
295    source: &str,
296    reason: &str,
297    site: CallSite,
298) {
299    security.audit(
300        AuditDecision::new(
301            caller,
302            capability,
303            context,
304            false,
305            source,
306            None,
307            Some(reason),
308        )
309        .with_site(site),
310    );
311}
312
313/// Audits a refusal at the entry of a host call as a call of its own, with the program
314/// line that led to it. For a refusal after the call's gate, see [`audit_denial_in`].
315pub(crate) fn audit_entry_denial(
316    store: &impl wasmtime::AsContext<Data = StoreData>,
317    caller: &str,
318    capability: &str,
319    context: &serde_json::Value,
320    source: &str,
321    reason: &str,
322) {
323    let security = store.as_context().data().security_check.as_ref();
324    let ticket = security
325        .recorder()
326        .map(|recorder| recorder.begin_call(caller, capability, call_origin(store)));
327    audit_denial_at(
328        security,
329        caller,
330        capability,
331        context,
332        source,
333        reason,
334        CallSite::new(ticket, EntryPath::GatedOp),
335    );
336}
337
338/// Audits a further refusal inside the host call `ticket` names, such as a read-only or
339/// quota refusal after the policy allowed it. Without a ticket the refusal is its own call.
340pub(crate) fn audit_denial_in(
341    store: &impl wasmtime::AsContext<Data = StoreData>,
342    ticket: Option<CallTicket>,
343    caller: &str,
344    capability: &str,
345    context: &serde_json::Value,
346    source: &str,
347    reason: &str,
348) {
349    audit_denial_at(
350        store.as_context().data().security_check.as_ref(),
351        caller,
352        capability,
353        context,
354        source,
355        reason,
356        CallSite::new(ticket, EntryPath::GatedOp),
357    );
358}
359
360/// Resolve a guest path for an operation that reaches its contents — every component,
361/// including the last, traversed under containment.
362pub fn resolve_content_or_trap(
363    data: &StoreData,
364    guest_path: &str,
365    op: &str,
366) -> wasmtime::Result<ContentPath> {
367    resolve_content(&data.vfs, data.vfs.cwd(), guest_path)
368        .map_err(|err| contain_trap(op, guest_path, &err))
369}
370
371/// Resolve a guest path for an operation that acts on the link itself — the final
372/// component is never followed.
373pub fn resolve_link_or_trap(
374    data: &StoreData,
375    guest_path: &str,
376    op: &str,
377) -> wasmtime::Result<LinkPath> {
378    resolve_link(&data.vfs, data.vfs.cwd(), guest_path)
379        .map_err(|err| contain_trap(op, guest_path, &err))
380}
381
382/// Refuse a write into a volume mounted read-only, attributed to the running
383/// package. Call after the capability check and resolution, before any other
384/// work, so the policy sees every attempt and a refused call changes nothing.
385/// `ticket` is the host call's gate, which the refusal continues.
386pub(crate) fn require_writable(
387    store: impl wasmtime::AsContext<Data = StoreData>,
388    ticket: Option<CallTicket>,
389    placement: &Placement,
390    capability: &str,
391    guest_path: &str,
392) -> wasmtime::Result<()> {
393    if placement.access() == Access::ReadWrite {
394        return Ok(());
395    }
396    let caller = running_package(&store).map_err(|error| {
397        if let PrincipalError::Unknown(ref unknown) = error {
398            audit_denial_in(
399                &store,
400                ticket,
401                unknown.label,
402                capability,
403                &serde_json::json!({ "path": guest_path }),
404                "invariant",
405                unknown.reason,
406            );
407        }
408        error.into_denial(capability)
409    })?;
410    audit_denial_in(
411        &store,
412        ticket,
413        &caller,
414        capability,
415        &serde_json::json!({ "path": guest_path }),
416        "read_only",
417        "the destination volume is read-only",
418    );
419    Err(read_only_denial(
420        &caller,
421        capability,
422        guest_path,
423        placement.mount_point(),
424    ))
425}
426
427fn read_only_denial(
428    caller: &str,
429    capability: &str,
430    guest_path: &str,
431    mount: &str,
432) -> wasmtime::Error {
433    permission_denied_read_only(
434        caller,
435        capability,
436        format!("{guest_path} is in the volume mounted read-only at {mount}"),
437    )
438}
439
440/// The single translation from a containment failure to a guest-visible trap. Every
441/// escape reaches the guest as the same diagnostic regardless of which operation hit it,
442/// so an LLM reading one recognises the rest.
443///
444/// A read-only refusal that got past [`require_writable`] still surfaces as a
445/// `PermissionDeniedError`, attributed to `<vfs>` since the caller is not at hand.
446pub fn contain_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
447    if let ContainError::ReadOnly(mount) = err {
448        return read_only_denial("<vfs>", op, guest_path, mount);
449    }
450    wasmtime::Error::msg(format!("{op} {guest_path}: {err}"))
451}
452
453/// Distinguish a missing parent from an escaping one at the point a write first touches
454/// the filesystem.
455///
456/// Both surface as a failure to create, and keeping them apart is what an LLM needs: one
457/// is fixable with `mkdir`, the other can never succeed. The `parent.is_dir()` pre-check
458/// this replaced conflated them the other way — it reported every escape as a missing
459/// parent, costing a turn on a `mkdir` that could never work.
460pub fn write_target_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
461    match err {
462        ContainError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
463            wasmtime::Error::msg(format!(
464                "{op} {guest_path}: parent directory does not exist \
465                 (create it with mkdir(path, true))"
466            ))
467        }
468        _ => contain_trap(op, guest_path, err),
469    }
470}
471
472/// A write refused because it would pass the VFS's size limit, as a `QuotaExceededError`
473/// the program can catch.
474pub(crate) fn quota_refusal(
475    op: &str,
476    guest_path: &str,
477    exceeded: QuotaExceeded,
478) -> wasmtime::Error {
479    quota_exceeded_error(format!("{op} {guest_path}: {exceeded}"))
480}
481
482// --- shared by the model-listing libraries (`submilli:llm`, `submilli:embedding`) ---
483
484/// Characters a `Model.description` may carry into a guest model's
485/// model-selection reasoning.
486///
487/// The bound is not the whole defense — [`sanitize_description`] strips control
488/// characters and collapses the text to one line first, because a length bound
489/// alone does not stop an injection short enough to fit. The bound is what stops
490/// a long one, and what keeps a listing's size independent of how much prose an
491/// operator wrote.
492pub const MAX_DESCRIPTION_CHARS: usize = 280;
493
494/// Whether a per-candidate check's answer removes the candidate (`Ok(false)`),
495/// keeps it (`Ok(true)`), or must propagate (`Err`).
496///
497/// Only the policy's own answer filters. An invariant denial means the check
498/// could not be made at all — the caller could not be named, or a runtime rule
499/// refused ahead of the policy — and swallowing it would turn a runtime refusal
500/// into a silently short listing that reads as "the operator configured fewer
501/// models."
502pub(crate) fn filters_candidate(checked: wasmtime::Result<()>) -> wasmtime::Result<bool> {
503    let Err(err) = checked else {
504        return Ok(true);
505    };
506    match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
507        Some(denial) if denial.is_policy() => Ok(false),
508        _ => Err(err),
509    }
510}
511
512/// The candidates of a listing the caller may use. `gate` runs the capability
513/// check one candidate's use would; a policy denial leaves the candidate out and
514/// marks the recorded decision filtered, and any other refusal propagates, the
515/// [`filters_candidate`] rule.
516pub(crate) fn permitted_candidates<T>(
517    caller: &mut wasmtime::Caller<'_, StoreData>,
518    candidates: Vec<T>,
519    name_of: impl Fn(&T) -> &str,
520    mut gate: impl FnMut(&mut wasmtime::Caller<'_, StoreData>, &str) -> wasmtime::Result<()>,
521) -> wasmtime::Result<Vec<T>> {
522    let mut permitted = Vec::with_capacity(candidates.len());
523    for candidate in candidates {
524        if filters_candidate(gate(caller, name_of(&candidate)))? {
525            permitted.push(candidate);
526        } else {
527            mark_filtered(&*caller);
528        }
529    }
530    Ok(permitted)
531}
532
533/// At most the first 200 bytes of a program-supplied value, for an error that
534/// echoes it: a refused value may be refused for its size.
535pub(crate) fn truncated(value: &str) -> std::borrow::Cow<'_, str> {
536    const MAX_ECHO_BYTES: usize = 200;
537    if value.len() <= MAX_ECHO_BYTES {
538        return std::borrow::Cow::Borrowed(value);
539    }
540    let end = value.floor_char_boundary(MAX_ECHO_BYTES);
541    std::borrow::Cow::Owned(format!("{}…", value.get(..end).unwrap_or_default()))
542}
543
544/// Reduce an operator-authored `description` to inert single-line data.
545///
546/// This is sanitization, not merely a bound. The text flows verbatim into a
547/// guest model's model-selection reasoning, so an injection that redirects which
548/// model a program calls fits comfortably inside any length limit — a bound
549/// alone stops only the long ones. Control characters and line breaks are what
550/// let injected text present itself as a new instruction block, so they are
551/// removed first; the bound then stops the rest.
552pub(crate) fn sanitize_description(description: &str) -> Option<String> {
553    let flattened: String = description
554        .chars()
555        .map(|c| if c.is_control() { ' ' } else { c })
556        .collect();
557    let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
558    if collapsed.is_empty() {
559        return None;
560    }
561    Some(collapsed.chars().take(MAX_DESCRIPTION_CHARS).collect())
562}
563
564/// Charge for the check and establish caller attribution even for an empty
565/// catalog, without asking policy about a model that does not exist.
566/// `count_field` names the filter context's count (`prompt_count`, `input_count`).
567pub(crate) fn preflight_models(
568    caller: &mut wasmtime::Caller<'_, StoreData>,
569    capability: &str,
570    count_field: &str,
571) -> wasmtime::Result<()> {
572    preflight_listing(
573        caller,
574        capability,
575        &serde_json::json!({ "model": "", count_field: 0 }),
576    )
577}
578
579/// [`preflight_models`] for any listing filtered per candidate: `context` is what
580/// an invariant denial is audited with, the context of an empty candidate.
581pub(crate) fn preflight_listing(
582    caller: &mut wasmtime::Caller<'_, StoreData>,
583    capability: &str,
584    context: &serde_json::Value,
585) -> wasmtime::Result<()> {
586    fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
587    running_package(caller).map(|_| ()).map_err(|error| {
588        if let PrincipalError::Unknown(ref unknown) = error {
589            audit_entry_denial(
590                &*caller,
591                unknown.label,
592                capability,
593                context,
594                "invariant",
595                unknown.reason,
596            );
597        }
598        error.into_denial(capability)
599    })
600}
601
602/// Audit a token-budget refusal against the running package, inside the host call
603/// `ticket` names. `Err` is the denial to throw instead when the package cannot
604/// be named.
605pub(crate) fn audit_quota_denial(
606    caller: &wasmtime::Caller<'_, StoreData>,
607    ticket: Option<CallTicket>,
608    capability: &str,
609    model: &str,
610    reason: &str,
611) -> Result<(), wasmtime::Error> {
612    let who = running_package(caller).or_else(PrincipalError::label_or_error)?;
613    audit_denial_in(
614        caller,
615        ticket,
616        &who,
617        capability,
618        &serde_json::json!({ "model": model }),
619        "quota",
620        reason,
621    );
622    Ok(())
623}
624
625/// A boxed number, or `undefined` when absent.
626pub(crate) fn optional_number(
627    caller: &mut wasmtime::Caller<'_, StoreData>,
628    number: Option<f64>,
629) -> wasmtime::Result<wasmtime::Val> {
630    match number {
631        Some(number) => Ok(wasmtime::Val::AnyRef(Some(
632            write_boxed_number_struct(caller, number)?.to_anyref(),
633        ))),
634        None => crate::runtime::prelude::undefined::value(caller),
635    }
636}