1use crate::runtime::approvals::{self, ApprovalRequest, Unasked};
4use crate::runtime::decision::{
5 CallOrigin, CallSite, CallTicket, EntryPath, PackageLine, SourceLine,
6};
7use crate::runtime::fs::{ContainError, ContentPath, LinkPath, resolve_content, resolve_link};
8use crate::runtime::fuel;
9use crate::runtime::host::{
10 permission_denied, permission_denied_invariant, permission_denied_read_only,
11 quota_exceeded_error, write_boxed_number_struct,
12};
13use crate::runtime::metrics::MetricsSink;
14use crate::runtime::security::{AuditDecision, CheckOutcome, SecurityCheck};
15use crate::runtime::vfs::{Access, Placement};
16use crate::runtime::{QuotaExceeded, StoreData};
17
18pub(crate) fn running_package(
36 store: &impl wasmtime::AsContext<Data = StoreData>,
37) -> Result<String, PrincipalError> {
38 let mut principal = None;
39 wasmtime::WasmBacktrace::visit_modules(store, |module| {
40 principal = Some(match module.name() {
41 Some(name) => owned_principal(name).map_err(PrincipalError::Internal),
42 None => Err(PrincipalError::Unknown(UnknownPrincipal {
43 label: "<unnamed module>",
44 reason: "the running module declares no package name, so its caller cannot be identified",
45 })),
46 });
47 std::ops::ControlFlow::Break(())
48 }).map_err(|error| PrincipalError::Internal(crate::runtime::host::fatal_host_error(error)))?;
49 principal.unwrap_or_else(|| {
50 Err(PrincipalError::Unknown(UnknownPrincipal {
51 label: "<no wasm frame>",
52 reason: "no wasm frame is executing, so the call has no caller to attribute it to",
53 }))
54 })
55}
56
57pub(crate) fn owned_principal(name: &str) -> wasmtime::Result<String> {
58 let mut result = String::new();
59 result
60 .try_reserve_exact(name.len())
61 .map_err(crate::runtime::host::fatal_host_error)?;
62 result.push_str(name);
63 Ok(result)
64}
65
66pub(crate) enum PrincipalError {
67 Unknown(UnknownPrincipal),
68 Internal(wasmtime::Error),
69}
70impl PrincipalError {
71 pub fn into_denial(self, capability: &str) -> wasmtime::Error {
72 match self {
73 Self::Unknown(unknown) => {
74 permission_denied_invariant(unknown.label, capability, unknown.reason)
75 }
76 Self::Internal(error) => error,
77 }
78 }
79
80 pub fn label_or_error(self) -> wasmtime::Result<String> {
81 match self {
82 Self::Unknown(unknown) => owned_principal(unknown.label),
83 Self::Internal(error) => Err(error),
84 }
85 }
86}
87
88pub(crate) struct UnknownPrincipal {
90 pub label: &'static str,
91 pub reason: &'static str,
92}
93
94pub fn check_security(
104 store: impl wasmtime::AsContextMut<Data = StoreData>,
105 capability: &str,
106 context: serde_json::Value,
107) -> wasmtime::Result<()> {
108 check_security_call(store, capability, context).map(|_| ())
109}
110
111pub(crate) fn check_security_call(
117 mut store: impl wasmtime::AsContextMut<Data = StoreData>,
118 capability: &str,
119 context: serde_json::Value,
120) -> wasmtime::Result<Option<CallTicket>> {
121 let (caller, ticket) = enter_gate(&mut store, capability, &context)?;
122 let data = store.as_context().data();
123 authorize_capability(
124 &caller,
125 data.security_check.as_ref(),
126 data.metrics.as_ref(),
127 capability,
128 &context,
129 data.vfs.cwd(),
130 CallSite::new(ticket, EntryPath::GatedOp),
131 )?;
132 Ok(ticket)
133}
134
135pub(crate) async fn check_and_approve_call(
138 caller: &mut wasmtime::Caller<'_, StoreData>,
139 capability: &str,
140 context: serde_json::Value,
141) -> wasmtime::Result<Option<CallTicket>> {
142 let (principal, ticket) = enter_gate(&mut *caller, capability, &context)?;
143 let site = CallSite::new(ticket, EntryPath::GatedOp);
144 let checked = {
145 let data = caller.data();
146 authorize_or_ask(
147 &principal,
148 data.security_check.as_ref(),
149 capability,
150 &context,
151 data.vfs.cwd(),
152 site,
153 )?
154 };
155 if let Checked::Ask(request) = checked {
156 approvals::ask(caller, request, site).await?;
157 }
158 Ok(ticket)
159}
160
161pub(crate) fn check_candidate(
166 mut store: impl wasmtime::AsContextMut<Data = StoreData>,
167 capability: &str,
168 context: serde_json::Value,
169) -> wasmtime::Result<()> {
170 let (caller, ticket) = enter_gate(&mut store, capability, &context)?;
171 let site = CallSite::new(ticket, EntryPath::GatedOp);
172 let data = store.as_context().data();
173 let security_check = data.security_check.as_ref();
174 match authorize_or_ask(
175 &caller,
176 security_check,
177 capability,
178 &context,
179 data.vfs.cwd(),
180 site,
181 )? {
182 Checked::Allowed => Ok(()),
183 Checked::Ask(_) if data.approval_provider.is_some() => Ok(()),
184 Checked::Ask(request) => {
186 let denial = approvals::deny_unasked(&request, security_check, site);
187 Err(permission_denied(
188 request.caller,
189 request.capability,
190 denial,
191 ))
192 }
193 }
194}
195
196fn enter_gate(
198 mut store: impl wasmtime::AsContextMut<Data = StoreData>,
199 capability: &str,
200 context: &serde_json::Value,
201) -> wasmtime::Result<(String, Option<CallTicket>)> {
202 fuel::charge_host_fuel(&mut store, fuel::GATE)?;
204 let caller = running_package(&store).map_err(|error| {
205 if let PrincipalError::Unknown(ref unknown) = error {
206 audit_entry_denial(
207 &store,
208 unknown.label,
209 capability,
210 context,
211 "invariant",
212 unknown.reason,
213 );
214 }
215 error.into_denial(capability)
216 })?;
217 let ticket = begin_recorded_call(&store, &caller, capability);
219 Ok((caller, ticket))
220}
221
222pub(crate) fn begin_recorded_call(
225 store: &impl wasmtime::AsContext<Data = StoreData>,
226 caller: &str,
227 capability: &str,
228) -> Option<CallTicket> {
229 let recorder = store.as_context().data().security_check.recorder()?;
230 Some(recorder.begin_call(caller, capability, call_origin(store)))
231}
232
233pub(crate) fn call_origin(store: &impl wasmtime::AsContext<Data = StoreData>) -> CallOrigin {
242 let Some(recorder) = store.as_context().data().security_check.recorder() else {
243 return CallOrigin::default();
244 };
245 if !recorder.wants_line() {
246 return CallOrigin::default();
247 }
248 let backtrace = wasmtime::WasmBacktrace::force_capture(store);
249 recorder.note_line_capture(backtrace.frames().len());
250 let frames = backtrace.frames();
251 let program = frames
252 .iter()
253 .position(|frame| frame.module().name() == Some(crate::mangle::USER_PACKAGE));
254 let line = program.and_then(|at| {
255 let symbol = frames[at].symbols().first()?;
256 Some(SourceLine {
257 line: symbol.line()?,
258 column: symbol.column(),
259 })
260 });
261 let package = frames[..program.unwrap_or(frames.len())]
263 .iter()
264 .find_map(package_line);
265 CallOrigin { line, package }
266}
267
268fn package_line(frame: &wasmtime::FrameInfo) -> Option<PackageLine> {
270 let package = frame.module().name()?;
271 let symbol = frame.symbols().first()?;
272 let module = symbol.file()?.strip_prefix(package)?.strip_prefix('/')?;
273 Some(PackageLine {
274 package: package.to_owned(),
275 module: module.to_owned(),
276 function: symbol.name().map(ToString::to_string),
277 line: symbol.line()?,
278 column: symbol.column(),
279 })
280}
281
282pub(crate) fn mark_filtered(store: &impl wasmtime::AsContext<Data = StoreData>) {
285 if let Some(recorder) = store.as_context().data().security_check.recorder() {
286 recorder.mark_last_filtered();
287 }
288}
289
290pub(crate) fn authorize_capability(
294 caller: &str,
295 security_check: &dyn SecurityCheck,
296 metrics: &dyn MetricsSink,
297 capability: &str,
298 context: &serde_json::Value,
299 cwd: &str,
300 site: CallSite,
301) -> wasmtime::Result<()> {
302 match authorize_or_ask(caller, security_check, capability, context, cwd, site)? {
303 Checked::Allowed => Ok(()),
304 Checked::Ask(request) => Err(approvals::refuse(
305 request,
306 Unasked::CannotWait,
307 security_check,
308 metrics,
309 site,
310 )),
311 }
312}
313
314fn authorize_or_ask(
317 caller: &str,
318 security_check: &dyn SecurityCheck,
319 capability: &str,
320 context: &serde_json::Value,
321 cwd: &str,
322 site: CallSite,
323) -> wasmtime::Result<Checked> {
324 if caller == crate::mangle::USER_PACKAGE
329 && let Some(reason) =
330 crate::stdlib::capabilities::find_any(capability).and_then(|entry| entry.main_denial)
331 {
332 audit_denial_at(
333 security_check,
334 caller,
335 capability,
336 context,
337 "invariant",
338 reason,
339 site,
340 );
341 return Err(permission_denied_invariant(caller, capability, reason));
342 }
343 check_or_ask(security_check, caller, capability, context, cwd, site)
344 .map_err(|reason| permission_denied(caller, capability, reason))
345}
346
347pub(crate) enum Checked {
349 Allowed,
350 Ask(ApprovalRequest),
352}
353
354pub(crate) fn check_and_audit(
358 security_check: &dyn SecurityCheck,
359 metrics: &dyn MetricsSink,
360 caller: &str,
361 capability: &str,
362 context: &serde_json::Value,
363 cwd: &str,
364 site: CallSite,
365) -> Result<(), String> {
366 match check_or_ask(security_check, caller, capability, context, cwd, site)? {
367 Checked::Allowed => Ok(()),
368 Checked::Ask(request) => {
369 request.report_unasked(Unasked::CannotWait, None, metrics);
370 Err(approvals::deny_unasked(&request, security_check, site))
371 }
372 }
373}
374
375pub(crate) fn check_or_ask(
378 security_check: &dyn SecurityCheck,
379 caller: &str,
380 capability: &str,
381 context: &serde_json::Value,
382 cwd: &str,
383 site: CallSite,
384) -> Result<Checked, String> {
385 let audit_context = security_check.audit_context(capability, context, cwd);
386 let (allowed, rule, reason) =
387 match security_check.check_with_cwd(caller, capability, context, cwd) {
388 CheckOutcome::Allow { rule } => (true, rule, None),
389 CheckOutcome::Deny { reason, rule } => (false, rule, Some(reason)),
390 CheckOutcome::Ask { approver, rule } => {
391 return Ok(Checked::Ask(ApprovalRequest {
392 approver,
393 caller: caller.to_owned(),
394 capability: capability.to_owned(),
395 context: audit_context.into_owned(),
396 rule,
397 }));
398 }
399 };
400 let explanation = security_check
402 .recorder()
403 .and_then(|_| security_check.explain(caller, capability, context, cwd));
404 security_check.audit(
405 AuditDecision::new(
406 caller,
407 capability,
408 audit_context.as_ref(),
409 allowed,
410 "policy",
411 rule,
412 reason.as_deref(),
413 )
414 .with_explanation(explanation.as_ref())
415 .with_site(site),
416 );
417 match reason {
418 None => Ok(Checked::Allowed),
419 Some(reason) => Err(reason),
420 }
421}
422
423pub(crate) fn audit_denial_at(
424 security: &dyn SecurityCheck,
425 caller: &str,
426 capability: &str,
427 context: &serde_json::Value,
428 source: &str,
429 reason: &str,
430 site: CallSite,
431) {
432 security.audit(
433 AuditDecision::new(
434 caller,
435 capability,
436 context,
437 false,
438 source,
439 None,
440 Some(reason),
441 )
442 .with_site(site),
443 );
444}
445
446pub(crate) fn audit_entry_denial(
449 store: &impl wasmtime::AsContext<Data = StoreData>,
450 caller: &str,
451 capability: &str,
452 context: &serde_json::Value,
453 source: &str,
454 reason: &str,
455) {
456 let security = store.as_context().data().security_check.as_ref();
457 let ticket = security
458 .recorder()
459 .map(|recorder| recorder.begin_call(caller, capability, call_origin(store)));
460 audit_denial_at(
461 security,
462 caller,
463 capability,
464 context,
465 source,
466 reason,
467 CallSite::new(ticket, EntryPath::GatedOp),
468 );
469}
470
471pub(crate) fn audit_denial_in(
474 store: &impl wasmtime::AsContext<Data = StoreData>,
475 ticket: Option<CallTicket>,
476 caller: &str,
477 capability: &str,
478 context: &serde_json::Value,
479 source: &str,
480 reason: &str,
481) {
482 audit_denial_at(
483 store.as_context().data().security_check.as_ref(),
484 caller,
485 capability,
486 context,
487 source,
488 reason,
489 CallSite::new(ticket, EntryPath::GatedOp),
490 );
491}
492
493pub fn resolve_content_or_trap(
496 data: &StoreData,
497 guest_path: &str,
498 op: &str,
499) -> wasmtime::Result<ContentPath> {
500 resolve_content(&data.vfs, data.vfs.cwd(), guest_path)
501 .map_err(|err| contain_trap(op, guest_path, &err))
502}
503
504pub fn resolve_link_or_trap(
507 data: &StoreData,
508 guest_path: &str,
509 op: &str,
510) -> wasmtime::Result<LinkPath> {
511 resolve_link(&data.vfs, data.vfs.cwd(), guest_path)
512 .map_err(|err| contain_trap(op, guest_path, &err))
513}
514
515pub(crate) fn require_writable(
520 store: impl wasmtime::AsContext<Data = StoreData>,
521 ticket: Option<CallTicket>,
522 placement: &Placement,
523 capability: &str,
524 guest_path: &str,
525) -> wasmtime::Result<()> {
526 if placement.access() == Access::ReadWrite {
527 return Ok(());
528 }
529 let caller = running_package(&store).map_err(|error| {
530 if let PrincipalError::Unknown(ref unknown) = error {
531 audit_denial_in(
532 &store,
533 ticket,
534 unknown.label,
535 capability,
536 &serde_json::json!({ "path": guest_path }),
537 "invariant",
538 unknown.reason,
539 );
540 }
541 error.into_denial(capability)
542 })?;
543 audit_denial_in(
544 &store,
545 ticket,
546 &caller,
547 capability,
548 &serde_json::json!({ "path": guest_path }),
549 "read_only",
550 "the destination volume is read-only",
551 );
552 Err(read_only_denial(
553 &caller,
554 capability,
555 guest_path,
556 placement.mount_point(),
557 ))
558}
559
560fn read_only_denial(
561 caller: &str,
562 capability: &str,
563 guest_path: &str,
564 mount: &str,
565) -> wasmtime::Error {
566 permission_denied_read_only(
567 caller,
568 capability,
569 format!("{guest_path} is in the volume mounted read-only at {mount}"),
570 )
571}
572
573pub fn contain_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
580 if let ContainError::ReadOnly(mount) = err {
581 return read_only_denial("<vfs>", op, guest_path, mount);
582 }
583 wasmtime::Error::msg(format!("{op} {guest_path}: {err}"))
584}
585
586pub fn write_target_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
594 match err {
595 ContainError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
596 wasmtime::Error::msg(format!(
597 "{op} {guest_path}: parent directory does not exist \
598 (create it with mkdir(path, {{ recursive: true }}))"
599 ))
600 }
601 _ => contain_trap(op, guest_path, err),
602 }
603}
604
605pub(crate) fn quota_refusal(
608 op: &str,
609 guest_path: &str,
610 exceeded: QuotaExceeded,
611) -> wasmtime::Error {
612 quota_exceeded_error(format!("{op} {guest_path}: {exceeded}"))
613}
614
615pub const MAX_DESCRIPTION_CHARS: usize = 280;
626
627pub(crate) fn filters_candidate(checked: wasmtime::Result<()>) -> wasmtime::Result<bool> {
636 let Err(err) = checked else {
637 return Ok(true);
638 };
639 match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
640 Some(denial) if denial.is_policy() => Ok(false),
641 _ => Err(err),
642 }
643}
644
645pub(crate) fn permitted_candidates<T>(
650 caller: &mut wasmtime::Caller<'_, StoreData>,
651 candidates: Vec<T>,
652 name_of: impl Fn(&T) -> &str,
653 mut gate: impl FnMut(&mut wasmtime::Caller<'_, StoreData>, &str) -> wasmtime::Result<()>,
654) -> wasmtime::Result<Vec<T>> {
655 let mut permitted = Vec::with_capacity(candidates.len());
656 for candidate in candidates {
657 if filters_candidate(gate(caller, name_of(&candidate)))? {
658 permitted.push(candidate);
659 } else {
660 mark_filtered(&*caller);
661 }
662 }
663 Ok(permitted)
664}
665
666pub(crate) fn truncated(value: &str) -> std::borrow::Cow<'_, str> {
669 const MAX_ECHO_BYTES: usize = 200;
670 if value.len() <= MAX_ECHO_BYTES {
671 return std::borrow::Cow::Borrowed(value);
672 }
673 let end = value.floor_char_boundary(MAX_ECHO_BYTES);
674 std::borrow::Cow::Owned(format!("{}…", value.get(..end).unwrap_or_default()))
675}
676
677pub(crate) fn sanitize_description(description: &str) -> Option<String> {
686 let flattened: String = description
687 .chars()
688 .map(|c| if c.is_control() { ' ' } else { c })
689 .collect();
690 let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
691 if collapsed.is_empty() {
692 return None;
693 }
694 Some(collapsed.chars().take(MAX_DESCRIPTION_CHARS).collect())
695}
696
697pub(crate) fn preflight_models(
701 caller: &mut wasmtime::Caller<'_, StoreData>,
702 capability: &str,
703 count_field: &str,
704) -> wasmtime::Result<()> {
705 preflight_listing(
706 caller,
707 capability,
708 &serde_json::json!({ "model": "", count_field: 0 }),
709 )
710}
711
712pub(crate) fn preflight_listing(
715 caller: &mut wasmtime::Caller<'_, StoreData>,
716 capability: &str,
717 context: &serde_json::Value,
718) -> wasmtime::Result<()> {
719 fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
720 running_package(caller).map(|_| ()).map_err(|error| {
721 if let PrincipalError::Unknown(ref unknown) = error {
722 audit_entry_denial(
723 &*caller,
724 unknown.label,
725 capability,
726 context,
727 "invariant",
728 unknown.reason,
729 );
730 }
731 error.into_denial(capability)
732 })
733}
734
735pub(crate) fn audit_quota_denial(
739 caller: &wasmtime::Caller<'_, StoreData>,
740 ticket: Option<CallTicket>,
741 capability: &str,
742 model: &str,
743 reason: &str,
744) -> Result<(), wasmtime::Error> {
745 let who = running_package(caller).or_else(PrincipalError::label_or_error)?;
746 audit_denial_in(
747 caller,
748 ticket,
749 &who,
750 capability,
751 &serde_json::json!({ "model": model }),
752 "quota",
753 reason,
754 );
755 Ok(())
756}
757
758pub(crate) fn optional_number(
760 caller: &mut wasmtime::Caller<'_, StoreData>,
761 number: Option<f64>,
762) -> wasmtime::Result<wasmtime::Val> {
763 match number {
764 Some(number) => Ok(wasmtime::Val::AnyRef(Some(
765 write_boxed_number_struct(caller, number)?.to_anyref(),
766 ))),
767 None => crate::runtime::prelude::undefined::value(caller),
768 }
769}