Skip to main content

act_runtime/
store.rs

1//! The wasmtime store: host state, the WASI views, and the capability
2//! ceilings resolved for one component run.
3
4use anyhow::Result;
5use std::collections::BTreeMap;
6use std::sync::Arc;
7use wasmtime::component::ResourceTable;
8use wasmtime::{Engine, Store, StoreLimits, StoreLimitsBuilder};
9use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
10use wasmtime_wasi_http::WasiHttpCtx;
11use wasmtime_wasi_http::WasiHttpCtxView;
12
13use crate::info::ComponentInfo;
14use crate::{credentials, fs_policy, http_client, http_policy};
15
16/// Host state passed into the wasmtime store.
17pub struct HostState {
18    pub(crate) wasi: WasiCtx,
19    pub(crate) table: ResourceTable,
20    pub(crate) http: WasiHttpCtx,
21    pub(crate) http_hooks: http_policy::PolicyHttpHooks,
22    #[allow(dead_code)] // retained for Task 10 DNS resolver hook access
23    pub(crate) http_client: Arc<http_client::ActHttpClient>,
24    pub(crate) fs_ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
25    pub(crate) fs_effective_mode: act_policy::grant::PolicyMode,
26    pub(crate) fd_paths: fs_policy::FdPathMap,
27    /// Interactive-consent prompter + per-session decision cache, shared by
28    /// every `ask`-mode decision point (fs / http / sockets).
29    pub(crate) consent_prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
30    pub(crate) consent_cache: Arc<act_policy::consent::DecisionCache>,
31    /// The `act:credentials/store` implementation this run serves, or `None`
32    /// when no credential store is configured. Held behind an `Arc` because
33    /// the component actor reaches the same object to mark sessions live and
34    /// dead — see `spawn_component_actor`.
35    pub(crate) credentials: Option<Arc<credentials::CredentialHost>>,
36    /// The compiled `act:credentials` ceiling, consulted before any credential
37    /// is issued. Present even when `credentials` is `None`: the audit header
38    /// must report the class either way.
39    pub(crate) credentials_ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
40    /// Every declared capability class the host does not wire interception
41    /// for — i.e. every resolved ceiling minus `PHYSICALLY_INTERCEPTED`. A
42    /// class absent from this map has no ceiling and must be denied outright.
43    ///
44    /// Behind an `Arc` because `act:consent`'s gate is assembled per request
45    /// out of this state, and cloning the map on every semantic authorization
46    /// would be a fresh allocation for something that never changes after
47    /// instantiation.
48    pub(crate) semantic_ceilings:
49        Arc<BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>>>,
50    /// The reference the operator supplied for this component, as typed.
51    ///
52    /// Used to attribute a consent prompt to the artifact asking
53    /// (ACT-CONSENT.md §5), which is why it may not be derived from the
54    /// component's own manifest: a name the guest chose would let any
55    /// component borrow another's reputation on the one line a human answers.
56    pub(crate) component_ref: String,
57    /// Caps the component's wasm linear memory growth (via `store.limiter`).
58    /// Default `StoreLimits` is unlimited.
59    pub(crate) limits: StoreLimits,
60}
61impl HostState {
62    /// Build a policy-aware filesystem view.
63    pub(crate) fn policy_fs_view(&mut self) -> fs_policy::PolicyFilesystemCtxView<'_> {
64        fs_policy::PolicyFilesystemCtxView {
65            ctx: self.wasi.filesystem(),
66            table: &mut self.table,
67            ceiling: &self.fs_ceiling,
68            fd_paths: &mut self.fd_paths,
69            mode: self.fs_effective_mode,
70            prompter: self.consent_prompter.clone(),
71            cache: self.consent_cache.clone(),
72        }
73    }
74}
75impl WasiView for HostState {
76    fn ctx(&mut self) -> WasiCtxView<'_> {
77        WasiCtxView {
78            ctx: &mut self.wasi,
79            table: &mut self.table,
80        }
81    }
82}
83impl wasmtime_wasi_http::WasiHttpView for HostState {
84    fn http(&mut self) -> WasiHttpCtxView<'_> {
85        WasiHttpCtxView {
86            ctx: &mut self.http,
87            table: &mut self.table,
88            hooks: &mut self.http_hooks,
89        }
90    }
91}
92/// Whether this check is the local side of an outbound socket rather than a
93/// destination the component asked to reach.
94///
95/// Since wasmtime 48 an outbound `connect` on an unbound socket — and a
96/// `listen` on one — is preceded by a bind check carrying the *wildcard*
97/// address, because the OS is about to bind there implicitly. That is
98/// documented on `SocketAddrUse::TcpBind`: "the address that is passed to the
99/// check is the address provided to `bind` for explicit binds, or the wildcard
100/// address for implicit binds".
101///
102/// `0.0.0.0:0` is not a destination and no allowlist would ever name one, so
103/// putting it through the ceiling denies every outbound connection the
104/// allowlist was written to permit — which is what the wasmtime 48 upgrade
105/// first did.
106///
107/// Waving it through grants nothing by itself. Reaching a peer still has to
108/// pass `TcpConnect` on the real address; accepting one still has to pass
109/// `TcpListen`, and then `TcpAccept` per client. An explicit
110/// `bind("0.0.0.0:0")` is indistinguishable from the implicit one at this
111/// point and takes the same path, to the same effect and for the same reason:
112/// binding confers no reach on its own.
113pub(crate) fn is_local_implicit_bind(
114    addr: std::net::SocketAddr,
115    reason: wasmtime_wasi::sockets::SocketAddrUse,
116) -> bool {
117    use wasmtime_wasi::sockets::SocketAddrUse;
118    matches!(reason, SocketAddrUse::TcpBind | SocketAddrUse::UdpBind)
119        && addr.ip().is_unspecified()
120        && addr.port() == 0
121}
122
123/// The constraint list a capability class declares, or `None` when the class
124/// is absent from the manifest. A class declared as a bare table yields
125/// `Some(vec![])`, which is what tells a provider "declared, unconstrained".
126pub(crate) fn declared_constraints(
127    info: &ComponentInfo,
128    cap_id: &str,
129) -> Option<Vec<serde_json::Value>> {
130    info.std
131        .capabilities
132        .get(cap_id)
133        .map(|req| req.constraints.clone())
134}
135/// The capability classes over which credentials can leave the machine.
136///
137/// Deliberately a constant read by `warn_if_credentials_exfil_risk` itself
138/// rather than a list its caller assembles: a caller that wired up only
139/// `wasi:http` would reopen the identical channel under the id nobody checked,
140/// and nothing about that call would look wrong at the call site.
141const EXFIL_NETWORK_CAPS: [&str; 2] = [
142    act_types::constants::CAP_HTTP,
143    act_types::constants::CAP_SOCKETS,
144];
145/// Warn when a component that declares `act:credentials` also holds an `open`
146/// grant on a network class it declared a reachable ceiling for.
147///
148/// Reading credentials and reaching the network are each unremarkable alone;
149/// together they are an exfiltration channel
150/// (docs/specs/2026-08-03-act-credentials-design.md §4.1). Both network classes
151/// count — raw TCP over `wasi:sockets` exfiltrates exactly as well as HTTP does,
152/// so warning about `wasi:http` alone would leave the same channel open under a
153/// different id.
154///
155/// ## Why the grant alone is not the trigger
156///
157/// The reach is the *ceiling* — grant ∩ declaration — not the grant. Per
158/// `act_policy::effective`, a class the component never declared is forced to
159/// `Deny` (`effective.rs:100`), a class declared as a bare table with no
160/// constraints is likewise forced to `Deny` (`effective.rs:118`), and an `open`
161/// grant does not mean "everything": it collapses to `Allowlist` bounded by the
162/// declaration (`effective.rs:144`). So `--allow wasi:http` on a component that
163/// declared no hosts buys that component nothing at all, and warning about it
164/// would be a false positive — the fastest way to teach an operator to ignore
165/// every warning this host emits.
166///
167/// The condition is therefore `open` grant **and** a non-empty declaration:
168/// exactly the case where the operator has removed their own bound and the
169/// artifact's self-declaration is the only one left standing.
170///
171/// ## Why not `act::audit`
172///
173/// Emitted on this module's default target, like every other host advisory
174/// (`http_policy`, `fs_policy`). The audit target is not a general-purpose log:
175/// `AuditLayer::on_event` reconstructs a typed `CapDecisionRecord` and drops
176/// anything without both a `cap_id` and an `act.decision` field, while
177/// `crate::fmt_filter` excludes `act::audit` from the `fmt` layer precisely so
178/// audit events are rendered once, by `render.rs`. A prose warning addressed to
179/// `act::audit` therefore reaches neither layer and is silently swallowed. This
180/// is advice about a grant the operator chose, not a decision about a resource
181/// access, so the ordinary log is where it belongs.
182pub(crate) fn warn_if_credentials_exfil_risk(
183    info: &ComponentInfo,
184    grant_policy: &act_policy::grant::GrantPolicy,
185) {
186    if !info
187        .std
188        .capabilities
189        .has(act_policy::providers::credentials::CAP_CREDENTIALS)
190    {
191        return;
192    }
193
194    // Note: a declaration whose constraints are present but malformed is
195    // counted as reachable here, while `effective_*` parses it, logs
196    // "ignoring malformed ... constraint" and denies. Erring towards the
197    // warning on a manifest that is already being complained about is the
198    // safe side of that seam, and keeping this check on the raw constraint
199    // list avoids duplicating each class's constraint schema here.
200    let unbounded: Vec<&str> = EXFIL_NETWORK_CAPS
201        .iter()
202        .copied()
203        .filter(|cap_id| {
204            grant_policy.resolve(cap_id).mode == act_policy::grant::PolicyMode::Open
205                && declared_constraints(info, cap_id).is_some_and(|v| !v.is_empty())
206        })
207        .collect();
208    if unbounded.is_empty() {
209        return;
210    }
211
212    let classes = unbounded.join(" and ");
213    tracing::warn!(
214        component = %info.std.name,
215        open_grants = %classes,
216        "component declares act:credentials and is granted {classes} in open \
217         mode: an open grant adds no bound of your own, leaving the component's \
218         own declaration as the only limit on where it can reach — and it can \
219         send your credentials anywhere that declaration permits. Grant an \
220         allowlist you chose instead."
221    );
222}
223/// Create a new store with WASI context, preopening directories from resolved mounts.
224///
225/// `grant_policy` is intersected with the component's declared capabilities via
226/// `ProviderRegistry::with_builtins()`. Undeclared capability classes are always
227/// denied regardless of the grant.
228///
229/// `component_ref` is the reference the operator supplied, carried into host
230/// state so a consent prompt can name the artifact that is asking. It is a
231/// parameter rather than a field of `info` on purpose: `info` is the guest's
232/// own manifest, and ACT-CONSENT.md §5 forbids attributing the question to a
233/// name the guest chose.
234#[allow(clippy::too_many_arguments)]
235pub async fn create_store(
236    engine: &Engine,
237    preopens: &[fs_policy::Preopen],
238    grant_policy: &act_policy::grant::GrantPolicy,
239    info: &ComponentInfo,
240    max_memory: Option<usize>,
241    prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
242    cache: Arc<act_policy::consent::DecisionCache>,
243    credentials: Option<Arc<credentials::CredentialHost>>,
244    component_ref: &str,
245) -> Result<(
246    Store<HostState>,
247    Vec<(String, Arc<dyn act_policy::provider::CompiledCeiling>)>,
248)> {
249    use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
250    use act_policy::grant::PolicyMode;
251    use act_policy::provider::{CompiledCeiling, ProviderRegistry, ResourceOp};
252
253    let registry = ProviderRegistry::with_builtins();
254
255    // act:credentials plus an unbounded grant on either network class is an
256    // exfiltration channel — see `warn_if_credentials_exfil_risk` above. It
257    // resolves the classes it cares about itself, from the whole policy.
258    warn_if_credentials_exfil_risk(info, grant_policy);
259
260    let declared: BTreeMap<String, Vec<serde_json::Value>> = info
261        .std
262        .capabilities
263        .iter()
264        .map(|(id, req)| (id.clone(), req.constraints.clone()))
265        .collect();
266
267    let all = act_policy::ceilings::resolve_ceilings(&registry, &declared, grant_policy)
268        .await
269        .map_err(|e| anyhow::anyhow!("capability policy: {e}"))?;
270
271    // The four the host enforces by interception are taken by name; their
272    // ceilings are needed individually to wire the wasmtime hooks below.
273    //
274    // `ALWAYS_RESOLVED` guarantees a ceiling for every physical class, so this
275    // should never miss — but `act-runtime` is the embeddable crate that
276    // `acts-core` and a future gateway link against, and `create_store`
277    // already returns `Result`. An `act-policy` edit that ever broke that
278    // guarantee must surface as an error in the embedder's process, not as a
279    // panic reaching all the way up through it.
280    let take = |id: &str| -> Result<Arc<dyn CompiledCeiling>> {
281        all.get(id)
282            .cloned()
283            .ok_or_else(|| anyhow::anyhow!("no resolved ceiling for always-resolved class {id}"))
284    };
285    let fs_ceiling = take(act_types::constants::CAP_FILESYSTEM)?;
286    let fs_effective_mode = fs_ceiling.effective_mode();
287    let http_ceiling = take(act_types::constants::CAP_HTTP)?;
288    let sockets_ceiling = take(act_types::constants::CAP_SOCKETS)?;
289    let sockets_effective_mode = sockets_ceiling.effective_mode();
290    // `act:credentials` is a semantic class with no resource constraints, so
291    // its declaredness is carried by `Option` presence, not by the (always
292    // empty) constraint list — see `CredentialsProvider`'s doc comment. It is
293    // resolved even when this run has no credential store: the ceiling is
294    // what the audit header reports, and a component that declared the class
295    // but got nothing must still show up as `declared but not granted`.
296    let credentials_ceiling = take(act_policy::providers::credentials::CAP_CREDENTIALS)?;
297
298    // Captured for the instantiation audit header (Task 10), before any of
299    // them get moved into `HostState` / the hooks / the sockets closure
300    // below — an `Arc` clone here is cheap and keeps this function's
301    // enforcement wiring below untouched. Every class the host resolved
302    // belongs here, not just the four it wires interception for: the header
303    // is assembled from this vec alone, so a class left out of it is one no
304    // operator ever sees a mode for.
305    let ceilings: Vec<(String, Arc<dyn CompiledCeiling>)> =
306        all.iter().map(|(id, c)| (id.clone(), c.clone())).collect();
307
308    // Everything not among the four the host wires interception for is a
309    // declared semantic class with no host-side enforcement hook of its own;
310    // Task 3 reads this to gate `act:consent` requests against it. Absence
311    // from this map is what "undeclared" means at that gate.
312    //
313    // Filtered against `PHYSICALLY_INTERCEPTED`, not `ALWAYS_RESOLVED`: this
314    // is the security boundary (a physically-enforced class must not become
315    // reachable through consent too), and `PHYSICALLY_INTERCEPTED`'s doc
316    // names that as its own predicate rather than one that happens to share
317    // `ALWAYS_RESOLVED`'s membership today.
318    let semantic_ceilings: Arc<BTreeMap<String, Arc<dyn CompiledCeiling>>> = Arc::new(
319        all.into_iter()
320            .filter(|(id, _)| !act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&id.as_str()))
321            .collect(),
322    );
323
324    let mut builder = WasiCtxBuilder::new();
325    let mut preopen_pairs = Vec::with_capacity(preopens.len());
326    for mount in preopens {
327        builder
328            .preopened_dir(&mount.host, &mount.guest, wasmtime_wasi::FsPerms::ReadWrite)
329            .map_err(|e| {
330                anyhow::anyhow!(
331                    "failed to preopen host dir '{}' as guest '{}': {}",
332                    mount.host.display(),
333                    mount.guest,
334                    e
335                )
336            })?;
337        preopen_pairs.push((mount.guest.clone(), mount.host.clone()));
338    }
339
340    // Install sockets enforcement via ceiling.classify.
341    {
342        let sockets_ceiling_clone = sockets_ceiling.clone();
343        let prompter_clone = prompter.clone();
344        let cache_clone = cache.clone();
345        builder
346            .socket_addr_check(move |addr, reason| {
347                let sockets_ceiling = sockets_ceiling_clone.clone();
348                let prompter = prompter_clone.clone();
349                let cache = cache_clone.clone();
350                Box::pin(async move {
351                    use wasmtime_wasi::sockets::SocketAddrUse;
352
353                    if is_local_implicit_bind(addr, reason) {
354                        return true;
355                    }
356
357                    // Exhaustive on purpose: a `_` arm silently filed
358                    // wasmtime 48's new `TcpListen` and `TcpAccept` under
359                    // "udp", in the audit trail and in the attrs a rule
360                    // matches on. The next added variant should fail to
361                    // compile rather than repeat that.
362                    let proto = match reason {
363                        SocketAddrUse::TcpBind
364                        | SocketAddrUse::TcpListen
365                        | SocketAddrUse::TcpAccept
366                        | SocketAddrUse::TcpConnect => "tcp",
367                        SocketAddrUse::UdpBind
368                        | SocketAddrUse::UdpSend
369                        | SocketAddrUse::UdpReceive => "udp",
370                    };
371                    let key = format!("{}:{}", addr.ip(), addr.port());
372                    let op = ResourceOp {
373                        cap_id: act_types::constants::CAP_SOCKETS.to_string(),
374                        key: key.clone(),
375                        action: String::new(),
376                        attrs: serde_json::json!({"protocol": proto}),
377                    };
378                    let explained = sockets_ceiling.classify_explained(&op);
379                    let mode = sockets_effective_mode.to_string();
380                    match explained.decision {
381                        act_policy::Decision::Allow => {
382                            emit_cap_decision(&CapDecisionRecord::statik(
383                                act_types::constants::CAP_SOCKETS,
384                                &key,
385                                &op.action,
386                                Decision4::Allow,
387                                &mode,
388                                explained.rule,
389                            ));
390                            true
391                        }
392                        act_policy::Decision::Deny => {
393                            emit_cap_decision(&CapDecisionRecord::statik(
394                                act_types::constants::CAP_SOCKETS,
395                                &key,
396                                &op.action,
397                                Decision4::Deny,
398                                &mode,
399                                explained.rule,
400                            ));
401                            false
402                        }
403                        // Deliberately silent: `ask` has not resolved yet. The
404                        // record is emitted below once the consent cache /
405                        // prompter answers, mirroring `fs_policy::resolve_ask`.
406                        act_policy::Decision::Ask => {
407                            use act_policy::consent::ConsentAsk;
408                            let ask = ConsentAsk {
409                                cap_id: act_types::constants::CAP_SOCKETS.to_string(),
410                                key: key.clone(),
411                                summary: format!("socket {proto} {addr}"),
412                            };
413                            // Read before `prompter` moves into the spawned
414                            // task below — `unwrap_or(false)` on a join
415                            // failure would otherwise leave no way to tell
416                            // "no channel" from "the task panicked".
417                            let has_channel = prompter.has_channel();
418                            let allowed =
419                                tokio::spawn(
420                                    async move { cache.decide_cached(&*prompter, ask).await },
421                                )
422                                .await
423                                .unwrap_or(false);
424                            emit_cap_decision(&CapDecisionRecord::answered(
425                                act_types::constants::CAP_SOCKETS,
426                                &key,
427                                allowed,
428                                has_channel,
429                            ));
430                            allowed
431                        }
432                    }
433                })
434            })
435            .allow_tcp(true)
436            .allow_udp(true)
437            .allow_ip_name_lookup(sockets_effective_mode != PolicyMode::Deny);
438    }
439
440    let wasi = builder.build();
441
442    // The HTTP client's DNS resolver filters resolved IPs against the allow/deny
443    // CIDR rules — which the opaque `CompiledCeiling` does not expose — so build
444    // it from the full effective HttpConfig (declaration ∩ grant), not just the
445    // mode. (The hook uses the ceiling; this PEP path needs the raw rules.)
446    let http_effective = act_policy::effective::effective_http(
447        &act_policy::grant::to_http_config(grant_policy)?,
448        &info.std.capabilities,
449    )
450    .config;
451    let http_client = Arc::new(http_client::ActHttpClient::new(http_effective)?);
452
453    let state = HostState {
454        wasi,
455        table: ResourceTable::new(),
456        http: WasiHttpCtx::new(),
457        http_hooks: http_policy::PolicyHttpHooks::new(
458            http_ceiling,
459            http_client.clone(),
460            prompter.clone(),
461            cache.clone(),
462        ),
463        http_client,
464        fs_ceiling,
465        fs_effective_mode,
466        fd_paths: fs_policy::FdPathMap {
467            preopens: preopen_pairs,
468            by_rep: Default::default(),
469        },
470        consent_prompter: prompter,
471        consent_cache: cache,
472        credentials,
473        credentials_ceiling,
474        semantic_ceilings,
475        component_ref: component_ref.to_string(),
476        limits: match max_memory {
477            Some(bytes) => StoreLimitsBuilder::new().memory_size(bytes).build(),
478            None => StoreLimits::default(),
479        },
480    };
481    let mut store = Store::new(engine, state);
482    // Enforce the linear-memory cap: when the guest grows memory past the limit,
483    // `memory.grow` fails (the guest typically traps OOM) instead of letting the
484    // host process balloon. No-op when `max_memory` is None (default limits).
485    store.limiter(|state| &mut state.limits);
486    Ok((store, ceilings))
487}
488
489// ── Component info from custom section ──