Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# WIT_WORLD is consumed at COMPILE time by:
# - hyperlight_wasm_macro (proc-macro) -> std::env::var_os("WIT_WORLD").unwrap()
# - hyperlight_wasm/build.rs -> rerun-if-env-changed=WIT_WORLD
# - hyperlight_wasm_runtime/build.rs -> emits cfg(component) IFF WIT_WORLD is set
#
# Without it, hyperlight_wasm_runtime falls back to the legacy flatbuffer-based
# host-function ABI, while hyperlight-wasm-sandbox (built with WIT_WORLD via the
# proc-macro's host_bindgen!) uses the component-model ABI. Mixing the two yields
# "GuestError: Host function vector parameter missing length" at sandbox start.
#
# `just wasm test` exports WIT_WORLD via its recipe, but bare `cargo` invocations
# (e.g. from an IDE or a developer running `cargo test --manifest-path ...`)
# would otherwise miss it. Cargo looks for `.cargo/config.toml` by walking up the
# CWD tree, so we put it at the repo root to cover every workspace cargo call.
[env]
WIT_WORLD = { value = "src/wasm_sandbox/wit/sandbox-world.wasm", relative = true }
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ jobs:
sudo udevadm trigger --name-match=kvm
sudo chmod 666 /dev/kvm

- name: Check WIT artifact is in sync with .wit source
# sandbox-world.wasm is the compiled WIT used by host_bindgen!. It
# is consumed by transitive deps (hyperlight-wasm-runtime) before
# our crate's build scripts run, so it must live in git. Detect any
# drift between the committed artifact and the .wit source.
run: |
just wasm guest-compile-wit
git diff --exit-code -- src/wasm_sandbox/wit/sandbox-world.wasm

- name: Build
run: just wasm build

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ target/
.venv/
*.aot
*.wasm
# Exception: the compiled WIT artifact is checked in as a build input.
# host_bindgen! reads this file *before* our build scripts can run (it is
# consumed by transitive deps like hyperlight-wasm-runtime), so the file
# must exist from `git clone` time. CI regenerates and diffs to catch drift.
!src/wasm_sandbox/wit/sandbox-world.wasm
__pycache__/
*.so
*.pyd
Expand Down
105 changes: 105 additions & 0 deletions src/hyperlight_sandbox/src/credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Scoped-credential registry for outgoing HTTP requests.
//!
//! A [`CredentialEntry`] binds a logical credential identifier to the
//! metadata required to inject a token header at request time:
//!
//! * `target` — URL-prefix scope. The outgoing-handler only injects
//! the credential when the request URL starts with this prefix.
//! * `header` — HTTP header name (e.g. `"Authorization"`).
//! * `prefix` — Value prefix prepended to the resolved token
//! (e.g. `"Bearer "`).
//! * `resolver` — A host-side callback invoked on every credentialed
//! outgoing request to produce a fresh secret value. The host calls
//! the resolver synchronously from the WASI HTTP dispatch path, so
//! implementations should be fast and (where appropriate) memoise
//! internally. Errors returned by the resolver surface to the guest
//! as a request-level dispatch failure with a host-redacted message.
//!
//! The registry is populated by the host before the guest runs.
//! Guests bind a credential to a specific outgoing request via WIT
//! `attach`.

use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};

/// Host-side callback that produces the secret token value for a
/// credential at request-dispatch time.
///
/// The returned `String` is treated as the literal token; the host
/// prepends [`CredentialEntry::prefix`] to it to form the outgoing
/// header value.
///
/// On error, the returned diagnostic string is **dropped** by the
/// outgoing-handler before any guest-visible error is produced — it
/// is neither sent to the guest nor logged by this crate. The wire
/// path surfaces only a fixed `"credential resolver failed"`
/// indication. Resolver authors who need diagnostics should record
/// them inside the resolver itself (e.g. via the host's own logger)
/// before returning the `Err`.
pub type ResolverFn = Arc<dyn Fn() -> Result<String, String> + Send + Sync>;

/// Metadata for a single scoped credential.
#[derive(Clone)]
pub struct CredentialEntry {
/// URL-prefix scope. Only requests whose URL starts with this
/// value are eligible for credential injection.
pub target: String,

/// HTTP header name to set (e.g. `"Authorization"`).
pub header: String,

/// Value prefix prepended to the resolved token
/// (e.g. `"Bearer "`). May be empty.
pub prefix: String,

/// Resolver callback. Invoked on every credentialed outgoing
/// request; see [`ResolverFn`] for the contract.
pub resolver: ResolverFn,
}

impl fmt::Debug for CredentialEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// The resolver is a function pointer that may close over secret
// material; we never want it (or its captures) to appear in a
// log line, panic message, or `dbg!` output.
f.debug_struct("CredentialEntry")
.field("target", &self.target)
.field("header", &self.header)
.field("prefix", &self.prefix)
.field("resolver", &"<callback>")
.finish()
}
}

impl CredentialEntry {
/// Build a [`CredentialEntry`] whose resolver returns a fixed
/// token string on every invocation.
///
/// Convenience constructor for tests, examples, and trivially
/// short-lived secrets. Production callers that need refresh
/// behaviour (managed identities, OAuth, …) should construct
/// the entry directly with a custom [`ResolverFn`].
pub fn with_static_resolver(
target: impl Into<String>,
header: impl Into<String>,
prefix: impl Into<String>,
token: impl Into<String>,
) -> Self {
let token = token.into();
Self {
target: target.into(),
header: header.into(),
prefix: prefix.into(),
resolver: Arc::new(move || Ok(token.clone())),
}
}
}

/// Shared, thread-safe credential registry keyed by credential id.
pub type CredentialRegistry = Arc<Mutex<HashMap<String, CredentialEntry>>>;

/// Creates an empty credential registry.
pub fn empty_registry() -> CredentialRegistry {
Arc::new(Mutex::new(HashMap::new()))
}
72 changes: 64 additions & 8 deletions src/hyperlight_sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
extern crate alloc;

pub mod cap_fs;
pub mod credentials;
pub mod http;
pub mod network;
pub mod runtime;
Expand All @@ -17,6 +18,7 @@ pub use cap_fs::{
CapFs, DescriptorFlags, DescriptorStat, DescriptorType, Dir, DirPerms, FilePerms, FsError,
OpenFlags,
};
pub use credentials::{CredentialEntry, CredentialRegistry, ResolverFn};
pub use network::{HttpMethod, MethodFilter, NetworkPermission, NetworkPermissions};
use serde::{Deserialize, Serialize};
pub use tools::{ArgType, ToolRegistry, ToolSchema};
Expand Down Expand Up @@ -114,6 +116,7 @@ pub trait Guest: Sized {
tools: ToolRegistry,
network: std::sync::Arc<std::sync::Mutex<NetworkPermissions>>,
fs: std::sync::Arc<std::sync::Mutex<CapFs>>,
credentials: CredentialRegistry,
) -> Result<Self::Sandbox>;
}

Expand All @@ -138,15 +141,28 @@ pub struct Sandbox<G: Guest> {
inner: G::Sandbox,
network: std::sync::Arc<std::sync::Mutex<NetworkPermissions>>,
fs: std::sync::Arc<std::sync::Mutex<CapFs>>,
credentials: CredentialRegistry,
}

impl<G: Guest> Sandbox<G> {
/// Create a sandbox without filesystem access.
pub fn new(guest: G, config: SandboxConfig, tools: ToolRegistry) -> Result<Self> {
let network = std::sync::Arc::new(std::sync::Mutex::new(NetworkPermissions::new()));
let fs = std::sync::Arc::new(std::sync::Mutex::new(CapFs::new()));
let inner = guest.build(config, tools, network.clone(), fs.clone())?;
Ok(Self { inner, network, fs })
let credentials = credentials::empty_registry();
let inner = guest.build(
config,
tools,
network.clone(),
fs.clone(),
credentials.clone(),
)?;
Ok(Self {
inner,
network,
fs,
credentials,
})
}

/// Create a sandbox with a read-only input directory.
Expand All @@ -159,8 +175,20 @@ impl<G: Guest> Sandbox<G> {
let network = std::sync::Arc::new(std::sync::Mutex::new(NetworkPermissions::new()));
let fs = CapFs::new().with_input(input_dir)?;
let fs = std::sync::Arc::new(std::sync::Mutex::new(fs));
let inner = guest.build(config, tools, network.clone(), fs.clone())?;
Ok(Self { inner, network, fs })
let credentials = credentials::empty_registry();
let inner = guest.build(
config,
tools,
network.clone(),
fs.clone(),
credentials.clone(),
)?;
Ok(Self {
inner,
network,
fs,
credentials,
})
}

/// Execute guest code.
Expand Down Expand Up @@ -212,6 +240,24 @@ impl<G: Guest> Sandbox<G> {
.map_err(|_| anyhow::anyhow!("network mutex poisoned"))?
.allow_domain(target, methods)
}

/// Register a scoped credential that guests can later `attach` to
/// outgoing requests.
///
/// Must be called before `run()`. Credentials are immutable once
/// registered and persist for the lifetime of the sandbox.
pub fn register_credential(&self, id: impl Into<String>, entry: CredentialEntry) -> Result<()> {
let id = id.into();
let mut registry = self
.credentials
.lock()
.map_err(|_| anyhow::anyhow!("credential registry mutex poisoned"))?;
if registry.contains_key(&id) {
anyhow::bail!("credential '{}' already registered", id);
}
registry.insert(id, entry);
Ok(())
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -350,9 +396,19 @@ where
None => vfs,
};
let fs = std::sync::Arc::new(std::sync::Mutex::new(vfs));
let inner = self
.guest
.build(self.config, self.tools, network.clone(), fs.clone())?;
Ok(Sandbox { inner, network, fs })
let credentials = credentials::empty_registry();
let inner = self.guest.build(
self.config,
self.tools,
network.clone(),
fs.clone(),
credentials.clone(),
)?;
Ok(Sandbox {
inner,
network,
fs,
credentials,
})
}
}
1 change: 1 addition & 0 deletions src/javascript_sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ impl Guest for HyperlightJs {
tools: ToolRegistry,
network: std::sync::Arc<std::sync::Mutex<NetworkPermissions>>,
fs: std::sync::Arc<std::sync::Mutex<CapFs>>,
_credentials: hyperlight_sandbox::CredentialRegistry,
) -> Result<JsGuestSandbox> {
JsGuestSandbox::new(config, tools, network, fs)
}
Expand Down
33 changes: 33 additions & 0 deletions src/sdk/python/core/hyperlight_sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,39 @@ def output_path(self) -> str | None:
def allow_domain(self, target: str, methods: list[str] | None = None) -> None:
self._inner.allow_domain(target, methods)

def register_credential(
self,
id: str,
*,
target: str,
header: str = "Authorization",
prefix: str = "Bearer ",
resolver: Callable[[], str],
) -> None:
"""Register a scoped credential for outgoing HTTP requests.

Must be called before ``run()``. Guest code can then bind the
credential to an individual request via WIT ``attach``.

Args:
id: Unique identifier for this credential.
target: URL-prefix scope. Only requests whose URL starts
with this value are eligible for injection.
header: HTTP header name to set (default ``Authorization``).
prefix: Value prefix prepended to the resolved token
(default ``Bearer ``).
resolver: A callable invoked with no arguments on every
credentialed outgoing request to produce a fresh token
value as a ``str``. Called synchronously from the host
HTTP dispatch path, so it must be fast and thread-safe;
long-running fetches (e.g. IMDS, OAuth) should be
memoised by the caller. Any exception raised by the
callable surfaces to guest code as a host-redacted
request-level error (only the exception **type name**
is propagated; the message body is dropped).
"""
self._inner.register_credential(id, target, header, prefix, resolver)

def snapshot(self):
"""Capture the current sandbox state.

Expand Down
27 changes: 10 additions & 17 deletions src/sdk/python/pyo3_common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,8 @@ pub fn resolve_maybe_coroutine<'py>(
}

let asyncio = py.import("asyncio")?;
match asyncio.call_method1("run", (obj,)) {
Ok(result) => return Ok(result.unbind()),
Err(_) => {}
if let Ok(result) = asyncio.call_method1("run", (obj,)) {
return Ok(result.unbind());
}

let resolver = PyModule::from_code(
Expand Down Expand Up @@ -348,20 +347,14 @@ fn infer_type_from_annotation(
// Handle Annotated[T, ...] — unwrap to get the base type T.
// typing.get_origin(ann) is typing.Annotated → typing.get_args(ann)[0] is T.
let py = annotation.py();
if let Ok(typing) = py.import("typing") {
if let Ok(origin) = typing.call_method1("get_origin", (&annotation,)) {
// Check if origin is typing.Annotated (available as typing.Annotated since 3.9+)
if let Ok(annotated_type) = typing.getattr("Annotated") {
if origin.is(&annotated_type) {
if let Ok(args) = typing.call_method1("get_args", (&annotation,)) {
// args is a tuple; first element is the base type.
if let Ok(base_type) = args.get_item(0) {
return type_obj_to_arg_type(&base_type);
}
}
}
}
}
if let Ok(typing) = py.import("typing")
&& let Ok(origin) = typing.call_method1("get_origin", (&annotation,))
&& let Ok(annotated_type) = typing.getattr("Annotated")
&& origin.is(&annotated_type)
&& let Ok(args) = typing.call_method1("get_args", (&annotation,))
&& let Ok(base_type) = args.get_item(0)
{
return type_obj_to_arg_type(&base_type);
}

None
Expand Down
2 changes: 0 additions & 2 deletions src/sdk/python/wasm_backend/.cargo/config.toml

This file was deleted.

Loading
Loading