1use 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
16pub(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
86pub(crate) struct UnknownPrincipal {
88 pub label: &'static str,
89 pub reason: &'static str,
90}
91
92pub 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
109pub(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 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 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
144pub(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
155pub(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 let package = frames[..program.unwrap_or(frames.len())]
185 .iter()
186 .find_map(package_line);
187 CallOrigin { line, package }
188}
189
190fn 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
204pub(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
212pub(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 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
245pub(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 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
313pub(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
338pub(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
360pub 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
371pub 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
382pub(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
440pub 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
453pub 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
472pub(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
482pub const MAX_DESCRIPTION_CHARS: usize = 280;
493
494pub(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
512pub(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
533pub(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
544pub(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
564pub(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
579pub(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
602pub(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
625pub(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}