Skip to content

Commit c86ff7e

Browse files
committed
feat(credentials): resolver becomes a per-request callback
Replace the static-token storage model with a host-side callback that produces the token at request-dispatch time, so resolvers can refresh tokens (IMDS, OAuth, Key Vault, ...) without re-registering credentials. Core (`hyperlight_sandbox`): * `ResolverFn = Arc<dyn Fn() -> Result<String, String> + Send + Sync>`, invoked once per credentialed outgoing request. The registry mutex is released *before* the resolver runs, so a slow resolver cannot stall unrelated requests. * `CredentialEntry::with_static_resolver` for tests / short-lived tokens. * Manual `Debug` impl on `CredentialEntry` renders the resolver as `<callback>` so captured secrets cannot leak via logs, panics, or `dbg!` output. * Re-export `ResolverFn` from the crate root. Wire path (`wasi_impl/http_handler.rs`): * Clone the credential entry by id, drop the mutex, enforce scope (URL prefix) before the existing `allow_domain` network gate, invoke the resolver, filter any guest-set header of the same name (case-insensitive), then inject `<header>: <prefix><token>`. * On resolver `Err`, the diagnostic is dropped before crossing the guest boundary; the guest sees only `InternalError("credential resolver failed")`. `ResolverFn` rustdoc updated to match this behaviour (was previously inaccurate). Python SDK (`hyperlight_sandbox` / `wasm_backend`): * `register_credential(..., resolver: Callable[[], str])` accepts any zero-arg Python callable. The PyO3 bridge re-acquires the GIL on every invocation. When the resolver raises, only the exception **type name** is forwarded across the FFI boundary; the message body is dropped (it may have been assembled from secret material). Integration tests (`src/wasm_sandbox/tests/credential_integration.rs`): * `resolver_invoked_per_request` — atomic counter + rotating tokens prove the resolver runs on every request, not once at registration. * `resolver_failure_surfaces_as_error` — resolver returns a secret-bearing diagnostic; the test asserts the diagnostic is absent from guest stdout and from the surfaced error payload. * `isolated_registries_across_sandboxes` — two sandboxes register the same credential id with different tokens; each sees only its own. Hygiene: * Fix joined-line whitespace bug in `wasm_backend/src/lib.rs`. * Sort `sandbox_executor` imports in guest `hyperlight.py` for the new `attach_credential` entry (was `I001`). Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent a5fd123 commit c86ff7e

7 files changed

Lines changed: 436 additions & 42 deletions

File tree

src/hyperlight_sandbox/src/credentials.rs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,39 @@
88
//! * `header` — HTTP header name (e.g. `"Authorization"`).
99
//! * `prefix` — Value prefix prepended to the resolved token
1010
//! (e.g. `"Bearer "`).
11-
//! * `resolver` — Opaque resolver identifier. Today this is a simple
12-
//! string key; a future commit will support async resolution
13-
//! callbacks.
11+
//! * `resolver` — A host-side callback invoked on every credentialed
12+
//! outgoing request to produce a fresh secret value. The host calls
13+
//! the resolver synchronously from the WASI HTTP dispatch path, so
14+
//! implementations should be fast and (where appropriate) memoise
15+
//! internally. Errors returned by the resolver surface to the guest
16+
//! as a request-level dispatch failure with a host-redacted message.
1417
//!
1518
//! The registry is populated by the host before the guest runs.
1619
//! Guests bind a credential to a specific outgoing request via WIT
1720
//! `attach`.
1821
1922
use std::collections::HashMap;
23+
use std::fmt;
2024
use std::sync::{Arc, Mutex};
2125

26+
/// Host-side callback that produces the secret token value for a
27+
/// credential at request-dispatch time.
28+
///
29+
/// The returned `String` is treated as the literal token; the host
30+
/// prepends [`CredentialEntry::prefix`] to it to form the outgoing
31+
/// header value.
32+
///
33+
/// On error, the returned diagnostic string is **dropped** by the
34+
/// outgoing-handler before any guest-visible error is produced — it
35+
/// is neither sent to the guest nor logged by this crate. The wire
36+
/// path surfaces only a fixed `"credential resolver failed"`
37+
/// indication. Resolver authors who need diagnostics should record
38+
/// them inside the resolver itself (e.g. via the host's own logger)
39+
/// before returning the `Err`.
40+
pub type ResolverFn = Arc<dyn Fn() -> Result<String, String> + Send + Sync>;
41+
2242
/// Metadata for a single scoped credential.
23-
#[derive(Debug, Clone)]
43+
#[derive(Clone)]
2444
pub struct CredentialEntry {
2545
/// URL-prefix scope. Only requests whose URL starts with this
2646
/// value are eligible for credential injection.
@@ -33,9 +53,47 @@ pub struct CredentialEntry {
3353
/// (e.g. `"Bearer "`). May be empty.
3454
pub prefix: String,
3555

36-
/// Opaque resolver identifier. The outgoing-handler will use
37-
/// this to obtain the actual secret value at dispatch time.
38-
pub resolver: String,
56+
/// Resolver callback. Invoked on every credentialed outgoing
57+
/// request; see [`ResolverFn`] for the contract.
58+
pub resolver: ResolverFn,
59+
}
60+
61+
impl fmt::Debug for CredentialEntry {
62+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63+
// The resolver is a function pointer that may close over secret
64+
// material; we never want it (or its captures) to appear in a
65+
// log line, panic message, or `dbg!` output.
66+
f.debug_struct("CredentialEntry")
67+
.field("target", &self.target)
68+
.field("header", &self.header)
69+
.field("prefix", &self.prefix)
70+
.field("resolver", &"<callback>")
71+
.finish()
72+
}
73+
}
74+
75+
impl CredentialEntry {
76+
/// Build a [`CredentialEntry`] whose resolver returns a fixed
77+
/// token string on every invocation.
78+
///
79+
/// Convenience constructor for tests, examples, and trivially
80+
/// short-lived secrets. Production callers that need refresh
81+
/// behaviour (managed identities, OAuth, …) should construct
82+
/// the entry directly with a custom [`ResolverFn`].
83+
pub fn with_static_resolver(
84+
target: impl Into<String>,
85+
header: impl Into<String>,
86+
prefix: impl Into<String>,
87+
token: impl Into<String>,
88+
) -> Self {
89+
let token = token.into();
90+
Self {
91+
target: target.into(),
92+
header: header.into(),
93+
prefix: prefix.into(),
94+
resolver: Arc::new(move || Ok(token.clone())),
95+
}
96+
}
3997
}
4098

4199
/// Shared, thread-safe credential registry keyed by credential id.

src/hyperlight_sandbox/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub use cap_fs::{
1818
CapFs, DescriptorFlags, DescriptorStat, DescriptorType, Dir, DirPerms, FilePerms, FsError,
1919
OpenFlags,
2020
};
21-
pub use credentials::{CredentialEntry, CredentialRegistry};
21+
pub use credentials::{CredentialEntry, CredentialRegistry, ResolverFn};
2222
pub use network::{HttpMethod, MethodFilter, NetworkPermission, NetworkPermissions};
2323
use serde::{Deserialize, Serialize};
2424
pub use tools::{ArgType, ToolRegistry, ToolSchema};

src/sdk/python/core/hyperlight_sandbox/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def register_credential(
187187
target: str,
188188
header: str = "Authorization",
189189
prefix: str = "Bearer ",
190-
resolver: str,
190+
resolver: Callable[[], str],
191191
) -> None:
192192
"""Register a scoped credential for outgoing HTTP requests.
193193
@@ -201,8 +201,15 @@ def register_credential(
201201
header: HTTP header name to set (default ``Authorization``).
202202
prefix: Value prefix prepended to the resolved token
203203
(default ``Bearer ``).
204-
resolver: Opaque resolver key. Currently used as the
205-
literal token value (static credentials).
204+
resolver: A callable invoked with no arguments on every
205+
credentialed outgoing request to produce a fresh token
206+
value as a ``str``. Called synchronously from the host
207+
HTTP dispatch path, so it must be fast and thread-safe;
208+
long-running fetches (e.g. IMDS, OAuth) should be
209+
memoised by the caller. Any exception raised by the
210+
callable surfaces to guest code as a host-redacted
211+
request-level error (only the exception **type name**
212+
is propagated; the message body is dropped).
206213
"""
207214
self._inner.register_credential(id, target, header, prefix, resolver)
208215

src/sdk/python/wasm_backend/src/lib.rs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use std::collections::HashMap;
2+
use std::sync::Arc;
23

34
use hyperlight_sandbox::{
4-
DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, HttpMethod, Sandbox,
5+
DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, HttpMethod, ResolverFn, Sandbox,
56
SandboxBuilder, SandboxConfig,
67
};
78
use hyperlight_sandbox_pyo3_common::{
@@ -22,7 +23,37 @@ struct PendingCredential {
2223
target: String,
2324
header: String,
2425
prefix: String,
25-
resolver: String,
26+
resolver: ResolverFn,
27+
}
28+
29+
/// Wrap a Python callable as a [`ResolverFn`] suitable for storage in
30+
/// the credential registry.
31+
///
32+
/// On each invocation the wrapper re-acquires the Python GIL, calls
33+
/// the supplied callable with no arguments, and extracts the result
34+
/// as a Python `str`. Exceptions are mapped to a redacted Rust error
35+
/// — only the exception **type name** is surfaced, never the message
36+
/// (which may contain secret material assembled by user code).
37+
fn python_callable_to_resolver(callable: Py<PyAny>) -> ResolverFn {
38+
Arc::new(move || -> Result<String, String> {
39+
Python::attach(|py| {
40+
let bound = callable.bind(py);
41+
match bound.call0() {
42+
Ok(result) => result
43+
.extract::<String>()
44+
.map_err(|_| "credential resolver did not return a str".to_string()),
45+
Err(err) => {
46+
let type_name = err
47+
.get_type(py)
48+
.qualname()
49+
.ok()
50+
.and_then(|n| n.extract::<String>().ok())
51+
.unwrap_or_else(|| "Exception".to_string());
52+
Err(format!("python resolver raised {type_name}"))
53+
}
54+
}
55+
})
56+
})
2657
}
2758

2859
#[pyclass]
@@ -192,15 +223,22 @@ impl WasmSandbox {
192223
///
193224
/// Must be called before `run()`. The credential can then be
194225
/// attached to individual requests by guest code via WIT `attach`.
226+
///
227+
/// `resolver` is a Python callable that takes no arguments and
228+
/// returns the secret token as a `str`. It is invoked synchronously
229+
/// from the WASI HTTP dispatch path on every credentialed request,
230+
/// so it must be fast and thread-safe; long-running token fetches
231+
/// should be memoised by the caller.
195232
#[pyo3(signature = (id, target, header, prefix, resolver))]
196233
fn register_credential(
197234
&mut self,
198235
id: &str,
199236
target: &str,
200237
header: &str,
201238
prefix: &str,
202-
resolver: &str,
239+
resolver: Py<PyAny>,
203240
) -> PyResult<()> {
241+
let resolver_fn = python_callable_to_resolver(resolver);
204242
if let Some(sandbox) = self.inner.as_ref() {
205243
// Register directly on the live sandbox.
206244
sandbox
@@ -210,7 +248,7 @@ impl WasmSandbox {
210248
target: target.to_string(),
211249
header: header.to_string(),
212250
prefix: prefix.to_string(),
213-
resolver: resolver.to_string(),
251+
resolver: resolver_fn,
214252
},
215253
)
216254
.map_err(|e| PyRuntimeError::new_err(format!("{e}")))?;
@@ -221,7 +259,7 @@ impl WasmSandbox {
221259
target: target.to_string(),
222260
header: header.to_string(),
223261
prefix: prefix.to_string(),
224-
resolver: resolver.to_string(),
262+
resolver: resolver_fn,
225263
});
226264
}
227265
Ok(())
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Hyperlight guest-side helpers, available to user code via `from hyperlight import call_tool`."""
22

33
from sandbox_executor import _call_tool as call_tool
4-
from sandbox_executor import http_get, http_post
5-
from sandbox_executor import attach_credential
4+
from sandbox_executor import attach_credential, http_get, http_post
65

76
__all__ = ["call_tool", "http_get", "http_post", "attach_credential"]

src/wasm_sandbox/src/wasi_impl/http_handler.rs

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,27 @@ impl
9090
// Scope is enforced BEFORE the network permission check so a
9191
// mis-scoped credential is rejected immediately with a clear
9292
// error rather than leaking through to the allow-list gate.
93+
//
94+
// The credential-registry mutex is dropped before the resolver
95+
// is invoked — resolvers may perform slow I/O (e.g. an IMDS
96+
// round-trip on cache miss) and we do not want to serialise
97+
// unrelated credentialed requests behind one slow resolver.
9398
// -----------------------------------------------------------------
9499
let credential_header: Option<(String, String)> =
95100
if let Some(ref cred_id) = request_data.attached_credential {
96-
let registry = self.credential_registry.lock().map_err(|_| {
97-
HyperlightError::Error("credential registry mutex poisoned".to_string())
98-
})?;
99-
let entry = match registry.get(cred_id) {
100-
Some(e) => e.clone(),
101-
// Defensive: attach() validated this, but the registry
102-
// could have been cleared between attach and dispatch.
103-
None => {
104-
return Ok(Err(ErrorCode::InternalError(Some(
105-
"attached credential not found in registry".to_string(),
106-
))));
101+
let entry = {
102+
let registry = self.credential_registry.lock().map_err(|_| {
103+
HyperlightError::Error("credential registry mutex poisoned".to_string())
104+
})?;
105+
match registry.get(cred_id) {
106+
Some(e) => e.clone(),
107+
// Defensive: attach() validated this, but the registry
108+
// could have been cleared between attach and dispatch.
109+
None => {
110+
return Ok(Err(ErrorCode::InternalError(Some(
111+
"attached credential not found in registry".to_string(),
112+
))));
113+
}
107114
}
108115
};
109116

@@ -113,10 +120,22 @@ impl
113120
return Ok(Err(ErrorCode::HTTPRequestDenied));
114121
}
115122

116-
// Resolve the token. For now the resolver string IS the
117-
// token value (static credentials). A future commit will
118-
// support async resolution callbacks.
119-
let header_value = format!("{}{}", entry.prefix, entry.resolver);
123+
// Resolve the token by invoking the registered
124+
// callback. The resolver returns the literal secret
125+
// value on success; on failure we surface a fixed,
126+
// host-redacted message so the guest never sees the
127+
// resolver's diagnostic text (which could contain
128+
// secret material).
129+
let token = match (entry.resolver)() {
130+
Ok(t) => t,
131+
Err(_diag) => {
132+
return Ok(Err(ErrorCode::InternalError(Some(
133+
"credential resolver failed".to_string(),
134+
))));
135+
}
136+
};
137+
138+
let header_value = format!("{}{}", entry.prefix, token);
120139
Some((entry.header.clone(), header_value))
121140
} else {
122141
None

0 commit comments

Comments
 (0)