1use std::io::Write;
4use std::sync::Arc;
5
6use crate::runtime::decision::{
7 CallOrigin, CallSite, CallTicket, EntryPath, PackageLine, SourceLine,
8};
9use crate::runtime::fs::{ContainError, ContentPath, LinkPath, resolve_content, resolve_link};
10use crate::runtime::fuel;
11use crate::runtime::host::{
12 permission_denied, permission_denied_invariant, permission_denied_read_only,
13 quota_exceeded_error, write_boxed_number_struct,
14};
15use crate::runtime::security::{AuditDecision, CheckOutcome, SecurityCheck};
16use crate::runtime::vfs::{Access, Placement};
17use crate::runtime::{DiskQuota, QuotaCharge, QuotaExceeded, StoreData};
18
19pub(crate) fn running_package(
37 store: &impl wasmtime::AsContext<Data = StoreData>,
38) -> Result<String, PrincipalError> {
39 let mut principal = None;
40 wasmtime::WasmBacktrace::visit_modules(store, |module| {
41 principal = Some(match module.name() {
42 Some(name) => owned_principal(name).map_err(PrincipalError::Internal),
43 None => Err(PrincipalError::Unknown(UnknownPrincipal {
44 label: "<unnamed module>",
45 reason: "the running module declares no package name, so its caller cannot be identified",
46 })),
47 });
48 std::ops::ControlFlow::Break(())
49 }).map_err(|error| PrincipalError::Internal(crate::runtime::host::fatal_host_error(error)))?;
50 principal.unwrap_or_else(|| {
51 Err(PrincipalError::Unknown(UnknownPrincipal {
52 label: "<no wasm frame>",
53 reason: "no wasm frame is executing, so the call has no caller to attribute it to",
54 }))
55 })
56}
57
58pub(crate) fn owned_principal(name: &str) -> wasmtime::Result<String> {
59 let mut result = String::new();
60 result
61 .try_reserve_exact(name.len())
62 .map_err(crate::runtime::host::fatal_host_error)?;
63 result.push_str(name);
64 Ok(result)
65}
66
67pub(crate) enum PrincipalError {
68 Unknown(UnknownPrincipal),
69 Internal(wasmtime::Error),
70}
71impl PrincipalError {
72 pub fn into_denial(self, capability: &str) -> wasmtime::Error {
73 match self {
74 Self::Unknown(unknown) => {
75 permission_denied_invariant(unknown.label, capability, unknown.reason)
76 }
77 Self::Internal(error) => error,
78 }
79 }
80
81 pub fn label_or_error(self) -> wasmtime::Result<String> {
82 match self {
83 Self::Unknown(unknown) => owned_principal(unknown.label),
84 Self::Internal(error) => Err(error),
85 }
86 }
87}
88
89pub(crate) struct UnknownPrincipal {
91 pub label: &'static str,
92 pub reason: &'static str,
93}
94
95pub fn check_security(
105 store: impl wasmtime::AsContextMut<Data = StoreData>,
106 capability: &str,
107 context: serde_json::Value,
108) -> wasmtime::Result<()> {
109 check_security_call(store, capability, context).map(|_| ())
110}
111
112pub(crate) fn check_security_call(
115 mut store: impl wasmtime::AsContextMut<Data = StoreData>,
116 capability: &str,
117 context: serde_json::Value,
118) -> wasmtime::Result<Option<CallTicket>> {
119 fuel::charge_host_fuel(&mut store, fuel::GATE)?;
121 let caller = running_package(&store).map_err(|error| {
122 if let PrincipalError::Unknown(ref unknown) = error {
123 audit_entry_denial(
124 &store,
125 unknown.label,
126 capability,
127 &context,
128 "invariant",
129 unknown.reason,
130 );
131 }
132 error.into_denial(capability)
133 })?;
134 let ticket = begin_recorded_call(&store, &caller, capability);
136 authorize_capability(
137 &caller,
138 store.as_context().data().security_check.as_ref(),
139 capability,
140 &context,
141 store.as_context().data().vfs.cwd(),
142 CallSite::new(ticket, EntryPath::GatedOp),
143 )?;
144 Ok(ticket)
145}
146
147pub(crate) fn begin_recorded_call(
150 store: &impl wasmtime::AsContext<Data = StoreData>,
151 caller: &str,
152 capability: &str,
153) -> Option<CallTicket> {
154 let recorder = store.as_context().data().security_check.recorder()?;
155 Some(recorder.begin_call(caller, capability, call_origin(store)))
156}
157
158pub(crate) fn call_origin(store: &impl wasmtime::AsContext<Data = StoreData>) -> CallOrigin {
167 let Some(recorder) = store.as_context().data().security_check.recorder() else {
168 return CallOrigin::default();
169 };
170 if !recorder.wants_line() {
171 return CallOrigin::default();
172 }
173 let backtrace = wasmtime::WasmBacktrace::force_capture(store);
174 recorder.note_line_capture(backtrace.frames().len());
175 let frames = backtrace.frames();
176 let program = frames
177 .iter()
178 .position(|frame| frame.module().name() == Some(crate::mangle::USER_PACKAGE));
179 let line = program.and_then(|at| {
180 let symbol = frames[at].symbols().first()?;
181 Some(SourceLine {
182 line: symbol.line()?,
183 column: symbol.column(),
184 })
185 });
186 let package = frames[..program.unwrap_or(frames.len())]
188 .iter()
189 .find_map(package_line);
190 CallOrigin { line, package }
191}
192
193fn package_line(frame: &wasmtime::FrameInfo) -> Option<PackageLine> {
195 let package = frame.module().name()?;
196 let symbol = frame.symbols().first()?;
197 let module = symbol.file()?.strip_prefix(package)?.strip_prefix('/')?;
198 Some(PackageLine {
199 package: package.to_owned(),
200 module: module.to_owned(),
201 function: symbol.name().map(ToString::to_string),
202 line: symbol.line()?,
203 column: symbol.column(),
204 })
205}
206
207pub(crate) fn mark_filtered(store: &impl wasmtime::AsContext<Data = StoreData>) {
210 if let Some(recorder) = store.as_context().data().security_check.recorder() {
211 recorder.mark_last_filtered();
212 }
213}
214
215pub(crate) fn authorize_capability(
218 caller: &str,
219 security_check: &dyn SecurityCheck,
220 capability: &str,
221 context: &serde_json::Value,
222 cwd: &str,
223 site: CallSite,
224) -> wasmtime::Result<()> {
225 if caller == crate::mangle::USER_PACKAGE
230 && let Some(reason) =
231 crate::stdlib::capabilities::find_any(capability).and_then(|entry| entry.main_denial)
232 {
233 audit_denial_at(
234 security_check,
235 caller,
236 capability,
237 context,
238 "invariant",
239 reason,
240 site,
241 );
242 return Err(permission_denied_invariant(caller, capability, reason));
243 }
244 check_and_audit(security_check, caller, capability, context, cwd, site)
245 .map_err(|reason| permission_denied(caller, capability, reason))
246}
247
248pub(crate) fn check_and_audit(
251 security_check: &dyn SecurityCheck,
252 caller: &str,
253 capability: &str,
254 context: &serde_json::Value,
255 cwd: &str,
256 site: CallSite,
257) -> Result<(), String> {
258 let outcome = security_check.check_with_cwd(caller, capability, context, cwd);
259 let explanation = security_check
261 .recorder()
262 .and_then(|_| security_check.explain(caller, capability, context, cwd));
263 let audit_context = security_check.audit_context(capability, context, cwd);
264 let context = audit_context.as_ref();
265 match outcome {
266 CheckOutcome::Allow { rule } => {
267 security_check.audit(
268 AuditDecision::new(caller, capability, context, true, "policy", rule, None)
269 .with_explanation(explanation.as_ref())
270 .with_site(site),
271 );
272 Ok(())
273 }
274 CheckOutcome::Deny { reason, rule } => {
275 security_check.audit(
276 AuditDecision::new(
277 caller,
278 capability,
279 context,
280 false,
281 "policy",
282 rule,
283 Some(&reason),
284 )
285 .with_explanation(explanation.as_ref())
286 .with_site(site),
287 );
288 Err(reason)
289 }
290 }
291}
292
293pub(crate) fn audit_denial_at(
294 security: &dyn SecurityCheck,
295 caller: &str,
296 capability: &str,
297 context: &serde_json::Value,
298 source: &str,
299 reason: &str,
300 site: CallSite,
301) {
302 security.audit(
303 AuditDecision::new(
304 caller,
305 capability,
306 context,
307 false,
308 source,
309 None,
310 Some(reason),
311 )
312 .with_site(site),
313 );
314}
315
316pub(crate) fn audit_entry_denial(
319 store: &impl wasmtime::AsContext<Data = StoreData>,
320 caller: &str,
321 capability: &str,
322 context: &serde_json::Value,
323 source: &str,
324 reason: &str,
325) {
326 let security = store.as_context().data().security_check.as_ref();
327 let ticket = security
328 .recorder()
329 .map(|recorder| recorder.begin_call(caller, capability, call_origin(store)));
330 audit_denial_at(
331 security,
332 caller,
333 capability,
334 context,
335 source,
336 reason,
337 CallSite::new(ticket, EntryPath::GatedOp),
338 );
339}
340
341pub(crate) fn audit_denial_in(
344 store: &impl wasmtime::AsContext<Data = StoreData>,
345 ticket: Option<CallTicket>,
346 caller: &str,
347 capability: &str,
348 context: &serde_json::Value,
349 source: &str,
350 reason: &str,
351) {
352 audit_denial_at(
353 store.as_context().data().security_check.as_ref(),
354 caller,
355 capability,
356 context,
357 source,
358 reason,
359 CallSite::new(ticket, EntryPath::GatedOp),
360 );
361}
362
363pub fn resolve_content_or_trap(
366 data: &StoreData,
367 guest_path: &str,
368 op: &str,
369) -> wasmtime::Result<ContentPath> {
370 resolve_content(&data.vfs, data.vfs.cwd(), guest_path)
371 .map_err(|err| contain_trap(op, guest_path, &err))
372}
373
374pub fn resolve_link_or_trap(
377 data: &StoreData,
378 guest_path: &str,
379 op: &str,
380) -> wasmtime::Result<LinkPath> {
381 resolve_link(&data.vfs, data.vfs.cwd(), guest_path)
382 .map_err(|err| contain_trap(op, guest_path, &err))
383}
384
385pub(crate) fn require_writable(
390 store: impl wasmtime::AsContext<Data = StoreData>,
391 ticket: Option<CallTicket>,
392 placement: &Placement,
393 capability: &str,
394 guest_path: &str,
395) -> wasmtime::Result<()> {
396 if placement.access() == Access::ReadWrite {
397 return Ok(());
398 }
399 let caller = running_package(&store).map_err(|error| {
400 if let PrincipalError::Unknown(ref unknown) = error {
401 audit_denial_in(
402 &store,
403 ticket,
404 unknown.label,
405 capability,
406 &serde_json::json!({ "path": guest_path }),
407 "invariant",
408 unknown.reason,
409 );
410 }
411 error.into_denial(capability)
412 })?;
413 audit_denial_in(
414 &store,
415 ticket,
416 &caller,
417 capability,
418 &serde_json::json!({ "path": guest_path }),
419 "read_only",
420 "the destination volume is read-only",
421 );
422 Err(read_only_denial(
423 &caller,
424 capability,
425 guest_path,
426 placement.mount_point(),
427 ))
428}
429
430pub(crate) fn refuse_volume_root(
433 resolved: &ContentPath,
434 op: &str,
435 guest_path: &str,
436) -> wasmtime::Result<()> {
437 if resolved.is_root() {
438 wasmtime::bail!(
439 "{op} {guest_path}: the VFS root or a mount point is a directory, not a file"
440 );
441 }
442 Ok(())
443}
444
445fn read_only_denial(
446 caller: &str,
447 capability: &str,
448 guest_path: &str,
449 mount: &str,
450) -> wasmtime::Error {
451 permission_denied_read_only(
452 caller,
453 capability,
454 format!("{guest_path} is in the volume mounted read-only at {mount}"),
455 )
456}
457
458pub fn contain_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
465 if let ContainError::ReadOnly(mount) = err {
466 return read_only_denial("<vfs>", op, guest_path, mount);
467 }
468 wasmtime::Error::msg(format!("{op} {guest_path}: {err}"))
469}
470
471pub fn write_target_trap(op: &str, guest_path: &str, err: &ContainError) -> wasmtime::Error {
479 match err {
480 ContainError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
481 wasmtime::Error::msg(format!(
482 "{op} {guest_path}: parent directory does not exist \
483 (create it with mkdir(path, true))"
484 ))
485 }
486 _ => contain_trap(op, guest_path, err),
487 }
488}
489
490pub(crate) fn quota_refusal(
493 op: &str,
494 guest_path: &str,
495 exceeded: QuotaExceeded,
496) -> wasmtime::Error {
497 quota_exceeded_error(format!("{op} {guest_path}: {exceeded}"))
498}
499
500pub(crate) fn atomic_write(
504 final_path: &ContentPath,
505 bytes: &[u8],
506 permissions: Option<cap_std::fs::Permissions>,
507 guest_path: &str,
508 op: &str,
509 quota: Option<Arc<DiskQuota>>,
510) -> wasmtime::Result<()> {
511 let mut disk_charge = QuotaCharge::new(quota, final_path.regular_file());
512 disk_charge
513 .reserve(bytes.len() as u64)
514 .map_err(|exceeded| quota_refusal(op, guest_path, exceeded))?;
515 write_then_rename(final_path, bytes, permissions, guest_path, op)?;
516 disk_charge.commit();
517 Ok(())
518}
519
520fn write_then_rename(
524 final_path: &ContentPath,
525 bytes: &[u8],
526 permissions: Option<cap_std::fs::Permissions>,
527 guest_path: &str,
528 op: &str,
529) -> wasmtime::Result<()> {
530 let tmp = final_path.temp_sibling();
531 let mut f = tmp
532 .create_new()
533 .map_err(|e| write_target_trap(op, guest_path, &e))?;
534 let result = (|| {
535 if let Some(permissions) = permissions {
536 f.set_permissions(permissions).map_err(|e| {
537 wasmtime::Error::msg(format!("{op} {guest_path}: permissions: {e}"))
538 })?;
539 }
540 f.write_all(bytes)
541 .map_err(|e| wasmtime::Error::msg(format!("{op} {guest_path}: {e}")))?;
542 f.sync_all()
543 .map_err(|e| wasmtime::Error::msg(format!("{op} {guest_path}: fsync: {e}")))?;
544 drop(f);
545 tmp.rename_to(final_path)
546 .map_err(|e| contain_trap(op, guest_path, &e))
547 })();
548 if result.is_err() {
549 let _ = tmp.remove_file();
550 }
551 result
552}
553
554pub const MAX_DESCRIPTION_CHARS: usize = 280;
565
566pub(crate) fn filters_candidate(checked: wasmtime::Result<()>) -> wasmtime::Result<bool> {
575 let Err(err) = checked else {
576 return Ok(true);
577 };
578 match err.downcast_ref::<crate::runtime::host::PermissionDenied>() {
579 Some(denial) if denial.is_policy() => Ok(false),
580 _ => Err(err),
581 }
582}
583
584pub(crate) fn permitted_candidates<T>(
589 caller: &mut wasmtime::Caller<'_, StoreData>,
590 candidates: Vec<T>,
591 name_of: impl Fn(&T) -> &str,
592 mut gate: impl FnMut(&mut wasmtime::Caller<'_, StoreData>, &str) -> wasmtime::Result<()>,
593) -> wasmtime::Result<Vec<T>> {
594 let mut permitted = Vec::with_capacity(candidates.len());
595 for candidate in candidates {
596 if filters_candidate(gate(caller, name_of(&candidate)))? {
597 permitted.push(candidate);
598 } else {
599 mark_filtered(&*caller);
600 }
601 }
602 Ok(permitted)
603}
604
605pub(crate) fn truncated(value: &str) -> std::borrow::Cow<'_, str> {
608 const MAX_ECHO_BYTES: usize = 200;
609 if value.len() <= MAX_ECHO_BYTES {
610 return std::borrow::Cow::Borrowed(value);
611 }
612 let end = value.floor_char_boundary(MAX_ECHO_BYTES);
613 std::borrow::Cow::Owned(format!("{}…", value.get(..end).unwrap_or_default()))
614}
615
616pub(crate) fn sanitize_description(description: &str) -> Option<String> {
625 let flattened: String = description
626 .chars()
627 .map(|c| if c.is_control() { ' ' } else { c })
628 .collect();
629 let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
630 if collapsed.is_empty() {
631 return None;
632 }
633 Some(collapsed.chars().take(MAX_DESCRIPTION_CHARS).collect())
634}
635
636pub(crate) fn preflight_models(
640 caller: &mut wasmtime::Caller<'_, StoreData>,
641 capability: &str,
642 count_field: &str,
643) -> wasmtime::Result<()> {
644 preflight_listing(
645 caller,
646 capability,
647 &serde_json::json!({ "model": "", count_field: 0 }),
648 )
649}
650
651pub(crate) fn preflight_listing(
654 caller: &mut wasmtime::Caller<'_, StoreData>,
655 capability: &str,
656 context: &serde_json::Value,
657) -> wasmtime::Result<()> {
658 fuel::charge_host_fuel(&mut *caller, fuel::GATE)?;
659 running_package(caller).map(|_| ()).map_err(|error| {
660 if let PrincipalError::Unknown(ref unknown) = error {
661 audit_entry_denial(
662 &*caller,
663 unknown.label,
664 capability,
665 context,
666 "invariant",
667 unknown.reason,
668 );
669 }
670 error.into_denial(capability)
671 })
672}
673
674pub(crate) fn audit_quota_denial(
678 caller: &wasmtime::Caller<'_, StoreData>,
679 ticket: Option<CallTicket>,
680 capability: &str,
681 model: &str,
682 reason: &str,
683) -> Result<(), wasmtime::Error> {
684 let who = running_package(caller).or_else(PrincipalError::label_or_error)?;
685 audit_denial_in(
686 caller,
687 ticket,
688 &who,
689 capability,
690 &serde_json::json!({ "model": model }),
691 "quota",
692 reason,
693 );
694 Ok(())
695}
696
697pub(crate) fn optional_number(
699 caller: &mut wasmtime::Caller<'_, StoreData>,
700 number: Option<f64>,
701) -> wasmtime::Result<wasmtime::Val> {
702 match number {
703 Some(number) => Ok(wasmtime::Val::AnyRef(Some(
704 write_boxed_number_struct(caller, number)?.to_anyref(),
705 ))),
706 None => crate::runtime::prelude::undefined::value(caller),
707 }
708}