use anyhow::Result;
use std::collections::BTreeMap;
use std::sync::Arc;
use wasmtime::component::ResourceTable;
use wasmtime::{Engine, Store, StoreLimits, StoreLimitsBuilder};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use wasmtime_wasi_http::WasiHttpCtx;
use wasmtime_wasi_http::WasiHttpCtxView;
use crate::info::ComponentInfo;
use crate::{credentials, fs_policy, http_client, http_policy};
pub struct HostState {
pub(crate) wasi: WasiCtx,
pub(crate) table: ResourceTable,
pub(crate) http: WasiHttpCtx,
pub(crate) http_hooks: http_policy::PolicyHttpHooks,
#[allow(dead_code)] pub(crate) http_client: Arc<http_client::ActHttpClient>,
pub(crate) fs_ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
pub(crate) fs_effective_mode: act_policy::grant::PolicyMode,
pub(crate) fd_paths: fs_policy::FdPathMap,
pub(crate) consent_prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
pub(crate) consent_cache: Arc<act_policy::consent::DecisionCache>,
pub(crate) credentials: Option<Arc<credentials::CredentialHost>>,
pub(crate) credentials_ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
pub(crate) semantic_ceilings:
Arc<BTreeMap<String, Arc<dyn act_policy::provider::CompiledCeiling>>>,
pub(crate) component_ref: String,
pub(crate) limits: StoreLimits,
}
impl HostState {
pub(crate) fn policy_fs_view(&mut self) -> fs_policy::PolicyFilesystemCtxView<'_> {
fs_policy::PolicyFilesystemCtxView {
ctx: self.wasi.filesystem(),
table: &mut self.table,
ceiling: &self.fs_ceiling,
fd_paths: &mut self.fd_paths,
mode: self.fs_effective_mode,
prompter: self.consent_prompter.clone(),
cache: self.consent_cache.clone(),
}
}
}
impl WasiView for HostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
impl wasmtime_wasi_http::WasiHttpView for HostState {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView {
ctx: &mut self.http,
table: &mut self.table,
hooks: &mut self.http_hooks,
}
}
}
pub(crate) fn is_local_implicit_bind(
addr: std::net::SocketAddr,
reason: wasmtime_wasi::sockets::SocketAddrUse,
) -> bool {
use wasmtime_wasi::sockets::SocketAddrUse;
matches!(reason, SocketAddrUse::TcpBind | SocketAddrUse::UdpBind)
&& addr.ip().is_unspecified()
&& addr.port() == 0
}
pub(crate) fn declared_constraints(
info: &ComponentInfo,
cap_id: &str,
) -> Option<Vec<serde_json::Value>> {
info.std
.capabilities
.get(cap_id)
.map(|req| req.constraints.clone())
}
const EXFIL_NETWORK_CAPS: [&str; 2] = [
act_types::constants::CAP_HTTP,
act_types::constants::CAP_SOCKETS,
];
pub(crate) fn warn_if_credentials_exfil_risk(
info: &ComponentInfo,
grant_policy: &act_policy::grant::GrantPolicy,
) {
if !info
.std
.capabilities
.has(act_policy::providers::credentials::CAP_CREDENTIALS)
{
return;
}
let unbounded: Vec<&str> = EXFIL_NETWORK_CAPS
.iter()
.copied()
.filter(|cap_id| {
grant_policy.resolve(cap_id).mode == act_policy::grant::PolicyMode::Open
&& declared_constraints(info, cap_id).is_some_and(|v| !v.is_empty())
})
.collect();
if unbounded.is_empty() {
return;
}
let classes = unbounded.join(" and ");
tracing::warn!(
component = %info.std.name,
open_grants = %classes,
"component declares act:credentials and is granted {classes} in open \
mode: an open grant adds no bound of your own, leaving the component's \
own declaration as the only limit on where it can reach — and it can \
send your credentials anywhere that declaration permits. Grant an \
allowlist you chose instead."
);
}
#[allow(clippy::too_many_arguments)]
pub async fn create_store(
engine: &Engine,
preopens: &[fs_policy::Preopen],
grant_policy: &act_policy::grant::GrantPolicy,
info: &ComponentInfo,
max_memory: Option<usize>,
prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
cache: Arc<act_policy::consent::DecisionCache>,
credentials: Option<Arc<credentials::CredentialHost>>,
component_ref: &str,
) -> Result<(
Store<HostState>,
Vec<(String, Arc<dyn act_policy::provider::CompiledCeiling>)>,
)> {
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
use act_policy::grant::PolicyMode;
use act_policy::provider::{CompiledCeiling, ProviderRegistry, ResourceOp};
let registry = ProviderRegistry::with_builtins();
warn_if_credentials_exfil_risk(info, grant_policy);
let declared: BTreeMap<String, Vec<serde_json::Value>> = info
.std
.capabilities
.iter()
.map(|(id, req)| (id.clone(), req.constraints.clone()))
.collect();
let all = act_policy::ceilings::resolve_ceilings(®istry, &declared, grant_policy)
.await
.map_err(|e| anyhow::anyhow!("capability policy: {e}"))?;
let take = |id: &str| -> Result<Arc<dyn CompiledCeiling>> {
all.get(id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("no resolved ceiling for always-resolved class {id}"))
};
let fs_ceiling = take(act_types::constants::CAP_FILESYSTEM)?;
let fs_effective_mode = fs_ceiling.effective_mode();
let http_ceiling = take(act_types::constants::CAP_HTTP)?;
let sockets_ceiling = take(act_types::constants::CAP_SOCKETS)?;
let sockets_effective_mode = sockets_ceiling.effective_mode();
let credentials_ceiling = take(act_policy::providers::credentials::CAP_CREDENTIALS)?;
let ceilings: Vec<(String, Arc<dyn CompiledCeiling>)> =
all.iter().map(|(id, c)| (id.clone(), c.clone())).collect();
let semantic_ceilings: Arc<BTreeMap<String, Arc<dyn CompiledCeiling>>> = Arc::new(
all.into_iter()
.filter(|(id, _)| !act_policy::ceilings::PHYSICALLY_INTERCEPTED.contains(&id.as_str()))
.collect(),
);
let mut builder = WasiCtxBuilder::new();
let mut preopen_pairs = Vec::with_capacity(preopens.len());
for mount in preopens {
builder
.preopened_dir(&mount.host, &mount.guest, wasmtime_wasi::FsPerms::ReadWrite)
.map_err(|e| {
anyhow::anyhow!(
"failed to preopen host dir '{}' as guest '{}': {}",
mount.host.display(),
mount.guest,
e
)
})?;
preopen_pairs.push((mount.guest.clone(), mount.host.clone()));
}
{
let sockets_ceiling_clone = sockets_ceiling.clone();
let prompter_clone = prompter.clone();
let cache_clone = cache.clone();
builder
.socket_addr_check(move |addr, reason| {
let sockets_ceiling = sockets_ceiling_clone.clone();
let prompter = prompter_clone.clone();
let cache = cache_clone.clone();
Box::pin(async move {
use wasmtime_wasi::sockets::SocketAddrUse;
if is_local_implicit_bind(addr, reason) {
return true;
}
let proto = match reason {
SocketAddrUse::TcpBind
| SocketAddrUse::TcpListen
| SocketAddrUse::TcpAccept
| SocketAddrUse::TcpConnect => "tcp",
SocketAddrUse::UdpBind
| SocketAddrUse::UdpSend
| SocketAddrUse::UdpReceive => "udp",
};
let key = format!("{}:{}", addr.ip(), addr.port());
let op = ResourceOp {
cap_id: act_types::constants::CAP_SOCKETS.to_string(),
key: key.clone(),
action: String::new(),
attrs: serde_json::json!({"protocol": proto}),
};
let explained = sockets_ceiling.classify_explained(&op);
let mode = sockets_effective_mode.to_string();
match explained.decision {
act_policy::Decision::Allow => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_SOCKETS,
&key,
&op.action,
Decision4::Allow,
&mode,
explained.rule,
));
true
}
act_policy::Decision::Deny => {
emit_cap_decision(&CapDecisionRecord::statik(
act_types::constants::CAP_SOCKETS,
&key,
&op.action,
Decision4::Deny,
&mode,
explained.rule,
));
false
}
act_policy::Decision::Ask => {
use act_policy::consent::ConsentAsk;
let ask = ConsentAsk {
cap_id: act_types::constants::CAP_SOCKETS.to_string(),
key: key.clone(),
summary: format!("socket {proto} {addr}"),
};
let has_channel = prompter.has_channel();
let allowed =
tokio::spawn(
async move { cache.decide_cached(&*prompter, ask).await },
)
.await
.unwrap_or(false);
emit_cap_decision(&CapDecisionRecord::answered(
act_types::constants::CAP_SOCKETS,
&key,
allowed,
has_channel,
));
allowed
}
}
})
})
.allow_tcp(true)
.allow_udp(true)
.allow_ip_name_lookup(sockets_effective_mode != PolicyMode::Deny);
}
let wasi = builder.build();
let http_effective = act_policy::effective::effective_http(
&act_policy::grant::to_http_config(grant_policy)?,
&info.std.capabilities,
)
.config;
let http_client = Arc::new(http_client::ActHttpClient::new(http_effective)?);
let state = HostState {
wasi,
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
http_hooks: http_policy::PolicyHttpHooks::new(
http_ceiling,
http_client.clone(),
prompter.clone(),
cache.clone(),
),
http_client,
fs_ceiling,
fs_effective_mode,
fd_paths: fs_policy::FdPathMap {
preopens: preopen_pairs,
by_rep: Default::default(),
},
consent_prompter: prompter,
consent_cache: cache,
credentials,
credentials_ceiling,
semantic_ceilings,
component_ref: component_ref.to_string(),
limits: match max_memory {
Some(bytes) => StoreLimitsBuilder::new().memory_size(bytes).build(),
None => StoreLimits::default(),
},
};
let mut store = Store::new(engine, state);
store.limiter(|state| &mut state.limits);
Ok((store, ceilings))
}