Scoped credentials for outgoing HTTP — host‑held secrets, guest‑held names#89
Open
simongdavies wants to merge 11 commits into
Open
Scoped credentials for outgoing HTTP — host‑held secrets, guest‑held names#89simongdavies wants to merge 11 commits into
simongdavies wants to merge 11 commits into
Conversation
Wipes the nanvix backend and every reference to it so the repo reads as if it never existed. - delete src/nanvix_sandbox/ crate (manifest, lib, examples, Justfile, lockfile) - drop the nanvix exclude entry and explanatory comment from root Cargo.toml - drop `mod nanvix` and the `nanvix::build` dependency from the root Justfile - remove the `nanvix-sandbox` job from .github/workflows/ci.yml - strip the Nanvix Sandbox section + TOC entry from README.md - drop the manual-version-bump bullet from RELEASE.md - rename the slide heading and remove the Nanvix guests section in docs/end-user-overview-slides.md - scrub the nanvix entries from the justfile-ci copilot skill notes Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Adds hyperlight:sandbox/credentials, a new interface for scoped credential injection in outgoing HTTP requests. Hosts register named credentials with a target URL scope, header name, value prefix and a resolver callback. Guests attach a credential by name to an outgoing-request before dispatch; the host injects the header at handle() time after verifying scope. Secret values never cross the WIT boundary. The new interface is imported by both the root world (componentize-py Python guests) and the inner sandbox world (own-shim guests). Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Satisfies the new RootImports trait shape produced by adding `import hyperlight:sandbox/credentials` to the root world. - Add `type Credentials = HostState; fn credentials(...)` to the `RootImports for HostState` impl, mirroring the pattern used for every other imported interface. - Add a stub `impl Credentials<Resource<OutgoingRequest>> for HostState` whose `attach()` rejects every call with `CredentialError::Unknown`. The stub deliberately makes the API behave as if no credentials are registered yet. This keeps the host-side trait surface satisfied so `just wasm::lint` (cargo clippy) passes after the WIT addition, without silently pretending credential injection works. Guests calling `attach` will get a loud `Unknown` error until the registry, scope match and header-injection paths land in follow-up commits. No registry, no resolver, no outgoing-handler hook in this commit. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Add the credential registry and functional attach() implementation: - New credentials module with CredentialEntry struct holding target (URL-prefix scope), header name, value prefix, and resolver. - CredentialRegistry type alias (Arc<Mutex<HashMap<String, ...>>>) with �mpty_registry() constructor. - HostState::register_credential() — host-side-only API for populating the registry before the guest runs. Not exposed via WIT. - Real �ttach() impl: validates credential exists in registry, checks for double-attach, writes binding into OutgoingRequest. - \�ttached_credential: Option<String>\ field on OutgoingRequest, initialised to None, set by attach(), ready for the outgoing-handler dispatch path to consume in follow-up commits. Error semantics: - Unknown: credential id not found in registry - AlreadyAttached: request already has a credential bound Token resolution and header injection land in commit D/E. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Wire the scoped-credential system into the HTTP outgoing-handler dispatch path in http_handler.rs: - After building request_url, check if the request has an attached credential. If so, look it up in the registry, enforce scope (URL prefix match), and resolve the token value. - Scope check runs BEFORE the network permission gate so a mis-scoped credential is rejected immediately with HTTPRequestDenied. - After collecting guest headers, filter out any guest-set header whose name matches the credential header (case-insensitive). This prevents the guest from overriding the host-injected token. - Inject the resolved \<header>: <prefix><token>\ pair. Token resolution is currently static: the resolver string on the CredentialEntry IS the token value. Async resolution callbacks are a planned follow-up. Security properties: - Credential scope enforced per-request (URL prefix match) - Guest cannot override host-injected auth headers - Registry lock released before any I/O - No credential values appear in error messages Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Move CredentialEntry and CredentialRegistry types from the Wasm backend
up to hyperlight_sandbox (the backend-agnostic mid-level crate) so any
backend can participate.
Changes across 6 crates:
hyperlight-sandbox (mid-level):
- New credentials module with CredentialEntry, CredentialRegistry,
and empty_registry().
- Guest::build() now accepts a CredentialRegistry parameter.
- Sandbox<G> stores the registry and exposes register_credential().
- SandboxBuilder threads the registry through to build().
hyperlight-wasm-sandbox:
- credentials.rs now re-exports from hyperlight_sandbox::credentials.
- Wasm::build() and WasmComponentSandbox::with_tools() accept the
registry instead of creating their own.
- Removed HostState::register_credential() — now lives on Sandbox<G>.
hyperlight-javascript-sandbox / hyperlight-nanvix-sandbox:
- Accept the credentials parameter in Guest::build() (ignored).
hyperlight-sandbox-backend-wasm (PyO3):
- register_credential() PyO3 method with pending buffer for lazy init.
- Pending credentials drained alongside pending networks on first run.
hyperlight-sandbox-pyo3-common:
- Fix clippy: match-with-single-pattern -> if let.
- Fix clippy: collapse nested if-let chain with let-chains.
hyperlight_sandbox Python SDK:
- Sandbox.register_credential() with keyword args for target, header,
prefix, and resolver.
Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
…aleness WIT_WORLD is consumed at compile time by hyperlight_wasm_macro (proc-macro), hyperlight_wasm/build.rs, and hyperlight_wasm_runtime/build.rs. The runtime build script emits cfg(component) only when WIT_WORLD is set; without it the runtime falls back to the legacy flatbuffer host-function ABI while the host, built via the proc-macro's host_bindgen!, always uses component-model bindings. The mismatch surfaces at sandbox start as 'GuestError: Host function vector parameter missing length'. The Justfile already exports WIT_WORLD for just children, but bare cargo invocations (IDE, CI direct cargo, developer ad-hoc) inherited nothing. The wasm_backend and dotnet/ffi sub-crates carry their own .cargo/config.toml as a workaround; a workspace-root .cargo/config.toml now covers every cargo invocation, so direct cargo test --manifest-path src/wasm_sandbox/Cargo.toml now matches the component-mode ABI just like just wasm test. Also extend _clean-stale-wasm to invalidate cached host bindings when the WIT source is newer than the cached fingerprints. Proc-macros cannot emit rerun-if-changed, so a WIT edit would otherwise leave stale bindings in target/. The Windows variant gets an explicit �xit 0 because Get-ChildItem -ErrorAction SilentlyContinue still flips $? to false. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Adds the missing guest-facing surface for the scoped-credentials feature defined in hyperlight:sandbox/credentials (Phase 1 host plumbing landed in the earlier WIT/RootImports/CredentialRegistry commits). Guest helper additions (src/wasm_sandbox/guests/python): - sandbox_executor.py: credential= kwarg on http_get / http_post / the shared _http_request helper, plus standalone attach_credential(req, id) for callers building wasi-http requests by hand. - hyperlight.py: re-export attach_credential so guest scripts can rom hyperlight import attach_credential. Tests (src/wasm_sandbox/tests/credential_integration.rs, 8 cases): - credential_header_injected_on_get / _on_post -> happy path - no_credential_means_no_auth_header -> default unchanged - duplicate_credential_registration_rejected -> registry rejects re-use - unknown_credential_raises_error -> unknown id surfaces error - guest_cannot_override_credential_header -> host injection wins - scope_mismatch_denied -> scope enforcement - double_attach_rejected -> single-attach invariant All 8 pass via just wasm test and direct cargo test. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
host_bindgen!() consumes a compiled WIT component (sandbox-world.wasm), which was previously .gitignored and only produced by `just wasm guest-compile-wit`. That left silent breakage for source-distribution consumers (`pip install git+...`, `cargo install`, etc.) because the artifact is read by transitive deps -- specifically the hyperlight-wasm-runtime proc-macro -- BEFORE our own build scripts can run. A build.rs in this crate cannot solve the bootstrap (the runtime is compiled first in the dep graph, and would race ABI with our fresh bindings even if it succeeded). The pragmatic, well-trodden fix used by most WIT-component projects: check the compiled artifact into git so it exists from clone time. Changes: - .gitignore: add `!src/wasm_sandbox/wit/sandbox-world.wasm` to opt this specific artifact out of the broad `*.wasm` exclusion. - Commit the current 16235-byte canonical artifact produced by `wasm-tools component wit ... -w`. - CI: add a `Check WIT artifact is in sync` step to the wasm-sandbox matrix job that regenerates the file via `just wasm guest-compile-wit` then `git diff --exit-code`s it, failing fast if a contributor edits the .wit source without committing the regenerated artifact. Developer flow stays the same: edit .wit, run `just wasm guest-compile-wit` (also chained inside `just wasm build`), commit both files together. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
The local config at src/sdk/python/wasm_backend/.cargo/config.toml has been present since the initial commit (b0c7399). It set: [env] WIT_WORLD = { value = "../../../../wasm_sandbox/wit/sandbox-world.wasm", relative = true } Cargo resolves `relative = true` against the directory containing the `.cargo/` directory (the workspace member root, not the `.cargo/` dir itself). Counting four `..` from src/sdk/python/wasm_backend/ lands at the *parent* of the repo root, then `wasm_sandbox/...` -> a path that does not exist. The real artifact lives at src/wasm_sandbox/... so the correct prefix would have been `../../../` (three `..`), not four. Because cargo merges configs deepest-first and this local file shadowed the root .cargo/config.toml (which is correctly written), every cargo invocation made from within src/sdk/python/wasm_backend/ -- including `maturin build` invoked by `pip install` -- saw WIT_WORLD pointing at a non-existent path. The hyperlight_wasm_macro::wasm_guest_bindgen proc macro in hyperlight-wasm-runtime then panicked with: proc macro panicked: called `Result::unwrap()` on an `Err` value: Os { code: 3, kind: NotFound, message: "The system cannot find the path specified." } This was masked everywhere it should have surfaced because every `just` recipe that exercises this code path exports WIT_WORLD as an absolute path before invoking cargo, bypassing the buggy resolution. The repo-root .cargo/config.toml (added previously) already covers every cargo invocation that walks up from inside the workspace, so the local config is redundant. Remove it to eliminate the shadow and let the correctly-resolved root config take effect. Verified empirically: in a fresh clone of feat/scoped-credentials at a853591, `cargo build --lib --release` from src/sdk/python/wasm_backend/ with no env vars set previously panicked at the proc-macro; after removing this file (the only change), the same command finishes the release build in ~1m18s. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
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>
Contributor
|
I really like this idea. There was some discussion on a wasi version similar to this in WebAssembly/wasi-config#16. The wasmcloud folks have thought this secret thing through a bit and use a similar approach with resources. I think something like what they have till probably end up in the wasi specs eventually. (I actually thought it already made it there but hasn't yet) This is very specific to the HTTP but i could see something like this being more generically usable. Do you think we could use something like they did or do we need to attach it specifically to the http request? My concern would be that it limits other use cases. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scoped credentials for outgoing HTTP — host-held secrets, guest-held names
Introduces a credential indirection for outgoing HTTP requests. The host
registers named credentials, each with a URL-scope, the header name to set,
a value prefix, and a resolver callback that produces the secret token.
The guest only ever sees the credential's opaque registered name and binds
it to an outgoing request via a new WIT call. At dispatch time the host
resolves the token, enforces the scope, and injects the header — all
after the guest has handed the request off. The secret never crosses
the guest boundary.
Security model
log,
printf, or exfiltrate the credential value. The token only existson the host side of the WASI HTTP dispatch path, between resolver
invocation and request transmission.
— a string the host chose. Knowing the name is not enough to use, fetch,
or reconstruct the secret without the host-side registry.
targetURLprefix. At dispatch the host checks the request URL prefix-matches the
credential's
targetbefore the existingallow_domainnetworkgate — a mis-scoped credential is rejected up-front. Both checks must
pass for the request to go out.
decided which credential header to inject, any guest-set header with the
same name (case-insensitive) is filtered out before the request is
built. The guest cannot smuggle its own
Authorization:past the host.error, only a fixed, host-redacted message (
"credential resolver failed") is surfaced as the request error. The resolver's owndiagnostic string is dropped before crossing the boundary — and
hyperlight_sandboxdoes not log it either, so anErrreturnedfrom inside a resolver cannot accidentally write secret material into a
host log. Resolver authors that need diagnostics should record them
inside the resolver itself (via their own logger) before returning the
Err; theResolverFnrustdoc spells this out. The Python SDK takesthis further: when a Python resolver raises, only the exception
type name is forwarded — the message body is discarded (it may have
been assembled from secret material).
Debug/ logs / panics.CredentialEntryhas a hand-written
Debugimpl that renders the resolver as"<callback>". The closure (and anything it captures) never appears inlog lines, panic backtraces, or
dbg!output.Sandbox<G>instance; there is no global table, nolazy_static, noshared
OnceCell. Two sandboxes that happen to use the same credentialid each see only their own resolver. Exercised by an integration test.
before the resolver runs, so a slow resolver (e.g. one that does a real
IMDS round-trip) cannot stall unrelated credentialed requests.
Flow
Sandbox::register_credential(id, CredentialEntry { target, header, prefix, resolver })before
run(). Registrations are immutable for the lifetime of thesandbox; duplicate ids are rejected.
wasi:http/outgoing-requestand calls the new WITimport
hyperlight:sandbox/credentials.attach(request, "<credential-id>").The guest never sets the credential header itself.
then dropped (→ guest-visible
InternalErrorif the id has gonemissing — defensive:
attachvalidated it on the way in),entry.target(→ guest-visibleHTTPRequestDeniedif not, beforethe network gate),
InternalError("credential resolver failed")on error — theresolver's diagnostic is dropped),
(case-insensitive),
<header>: <prefix><token>into the outgoing request,allow_domainnetwork gate.The guest observes only the outcome (success /
HTTPRequestDenied/InternalError). Steps 3.iii–v happen entirely host-side.API surface
Rust (
hyperlight_sandbox)New
credentialsmodule:Sandbox::register_credential(id, entry)— host-only API; rejectsduplicate ids; must be called before
run().Guest::buildnow receives theCredentialRegistryso backends canwire it into their outgoing-handler implementation. The registry is
plumbed only to the host-side dispatch path; it is not surfaced to the
guest in any form.
WIT (
hyperlight:sandbox/credentials)This is the only credential-related surface the guest sees.
attachitself returnsunknown/already-attached;scope-mismatchand
resolver-failedare produced at dispatch time as HTTP error codes(
HTTPRequestDenied/InternalError) sinceattachdoes not yet havethe request URL or run the resolver.
Python SDK (
hyperlight_sandbox)The Python callable is invoked synchronously from the WASI HTTP dispatch
path on every credentialed request. Exceptions are mapped to a redacted
host-side error containing only the exception type name — the message
body is dropped before crossing the boundary.
Guest Python helper (
hyperlight.attach_credential, plus acredential=kwarg on the existing
http_get/http_postwrappers) handles the WITattachcall for guest scripts.Backend coverage
A backend is the guest execution engine that runs inside the sandbox
and dispatches outgoing HTTP. The credential injection is implemented in
the host-side WASI HTTP outgoing-handler that each backend uses.
attachhonouredWasm)_)SDK coverage
An SDK is a language-side binding that exposes
Sandbox::register_credential(and the resolver shape) to hostapplication code. All SDKs target the Rust core API.
register_credentialexposedhyperlight_sandbox)ResolverFncallback +with_static_resolverhelperCallable[[], str]Build & CI
.cargo/config.tomlat the repo root setsWIT_WORLDfor everyworkspace
cargoinvocation. Without ithyperlight_wasm_runtime(a transitive dep) falls back to the legacy flatbuffer ABI while our
crate builds against the component-model ABI, producing the runtime
error "Host function vector parameter missing length" on sandbox
start.
justrecipes already exported it; this covers barecargoandIDE invocations. A previous, redundant
.cargo/config.tomlundersrc/wasm_sandbox/that shadowed the workspace one is also removed.sandbox-world.wasmfromhyperlight-sandbox.witand fails the buildon
git diff. The compiled artifact is consumed by transitive depsbefore our own build scripts run, so it must live in git — this catches
silent drift between the source and the committed artifact.
Out of scope (explicit follow-ups)
Fn() -> Result<String, String>and runs on the dispatch thread. Production resolvers
(IMDS, OAuth refresh, Key Vault) need async + caching/refresh; for now
caching is delegated to the resolver implementer. A future change can
introduce an async variant without breaking the sync surface.
resolvers ship in this PR — only the callback hook. Those will land as
separate, opt-in helpers (likely in their own crate) once the async
story is decided.
registry parameter but ignores it;
attachis not honoured on the JSdispatch path. Needs the same wiring as the Wasm backend.
register_credential. Python is done; .NEThas no FFI surface for it yet. A faithful binding has to marshal a C#
delegate across the C-ABI as the resolver callback, keep a GC root on
the
Func<string>for the sandbox's lifetime, and map any .NETexception thrown from the delegate back to Rust as the redacted
Err(String)— without letting the exception message ride across theboundary. A constrained first cut (FFI accepting only a static token,
mapped onto
CredentialEntry::with_static_resolver) is feasible inisolation, but is deferred so the .NET surface can be designed around
the callback shape from day one rather than around a static-only stub
that would later need a breaking rename.
resolver_failure_surfaces_as_errorasserts the failure diagnostic does not leak, and
isolated_registries_across_sandboxesproves cross-sandbox isolation,but there is no positive test that runs a guest with a known sentinel
token value and scans
stdout/stderr/ every error payload toassert the sentinel string is absent from every guest-visible
output.