Skip to content

Scoped credentials for outgoing HTTP — host‑held secrets, guest‑held names#89

Open
simongdavies wants to merge 11 commits into
hyperlight-dev:mainfrom
simongdavies:feat/scoped-credentials
Open

Scoped credentials for outgoing HTTP — host‑held secrets, guest‑held names#89
simongdavies wants to merge 11 commits into
hyperlight-dev:mainfrom
simongdavies:feat/scoped-credentials

Conversation

@simongdavies
Copy link
Copy Markdown
Member

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

  • Secrets never enter the guest address space. The guest cannot read,
    log, printf, or exfiltrate the credential value. The token only exists
    on the host side of the WASI HTTP dispatch path, between resolver
    invocation and request transmission.
  • Opaque handle. The guest holds only the credential's registered name
    — a string the host chose. Knowing the name is not enough to use, fetch,
    or reconstruct the secret without the host-side registry.
  • Scope-bound and gate-bound. Each credential carries a target URL
    prefix. At dispatch the host checks the request URL prefix-matches the
    credential's target before the existing allow_domain network
    gate — a mis-scoped credential is rejected up-front. Both checks must
    pass for the request to go out.
  • Guest cannot override the host-injected header. After the host has
    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.
  • No diagnostic leakage on resolver failure. If a resolver returns an
    error, only a fixed, host-redacted message ("credential resolver failed") is surfaced as the request error. The resolver's own
    diagnostic string is dropped before crossing the boundary — and
    hyperlight_sandbox does not log it either, so an Err returned
    from 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; the ResolverFn rustdoc spells this out. The Python SDK takes
    this 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).
  • Secrets cannot leak via Debug / logs / panics. CredentialEntry
    has a hand-written Debug impl that renders the resolver as
    "<callback>". The closure (and anything it captures) never appears in
    log lines, panic backtraces, or dbg! output.
  • Per-sandbox isolation. The registry is owned by an individual
    Sandbox<G> instance; there is no global table, no lazy_static, no
    shared OnceCell. Two sandboxes that happen to use the same credential
    id each see only their own resolver. Exercised by an integration test.
  • No lock held across the resolver. The registry mutex is released
    before the resolver runs, so a slow resolver (e.g. one that does a real
    IMDS round-trip) cannot stall unrelated credentialed requests.

Flow

  1. Host calls
    Sandbox::register_credential(id, CredentialEntry { target, header, prefix, resolver })
    before run(). Registrations are immutable for the lifetime of the
    sandbox; duplicate ids are rejected.
  2. Guest builds a wasi:http/outgoing-request and calls the new WIT
    import hyperlight:sandbox/credentials.attach(request, "<credential-id>").
    The guest never sets the credential header itself.
  3. Host outgoing-handler, at dispatch time:
    1. clones the credential entry out of the registry by id; the mutex is
      then dropped (→ guest-visible InternalError if the id has gone
      missing — defensive: attach validated it on the way in),
    2. enforces the scope: the request URL must prefix-match
      entry.target (→ guest-visible HTTPRequestDenied if not, before
      the network gate),
    3. invokes the resolver callback to obtain the token (→ host-redacted
      InternalError("credential resolver failed") on error — the
      resolver's diagnostic is dropped),
    4. filters any guest-set header matching the credential header
      (case-insensitive),
    5. injects <header>: <prefix><token> into the outgoing request,
    6. proceeds through the normal allow_domain network gate.

The guest observes only the outcome (success / HTTPRequestDenied /
InternalError). Steps 3.iii–v happen entirely host-side.

API surface

Rust (hyperlight_sandbox)

New credentials module:

pub type ResolverFn = Arc<dyn Fn() -> Result<String, String> + Send + Sync>;

pub struct CredentialEntry {
    pub target: String,    // URL-prefix scope
    pub header: String,    // e.g. "Authorization"
    pub prefix: String,    // e.g. "Bearer "
    pub resolver: ResolverFn,
}

impl CredentialEntry {
    /// Convenience for tests / short-lived static tokens.
    pub fn with_static_resolver(
        target: impl Into<String>,
        header: impl Into<String>,
        prefix: impl Into<String>,
        token:  impl Into<String>,
    ) -> Self;
}

pub type CredentialRegistry = Arc<Mutex<HashMap<String, CredentialEntry>>>;
  • Sandbox::register_credential(id, entry) — host-only API; rejects
    duplicate ids; must be called before run().
  • Guest::build now receives the CredentialRegistry so backends can
    wire 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)

interface credentials {
    use wasi:http/types@0.2.0.{outgoing-request};

    variant credential-error {
        unknown,
        scope-mismatch,
        resolver-failed(string),  // host-redacted, never the raw diagnostic
        already-attached,
    }

    attach: func(
        request:    borrow<outgoing-request>,
        credential: string,
    ) -> result<_, credential-error>;
}

This is the only credential-related surface the guest sees.
attach itself returns unknown / already-attached; scope-mismatch
and resolver-failed are produced at dispatch time as HTTP error codes
(HTTPRequestDenied / InternalError) since attach does not yet have
the request URL or run the resolver.

Python SDK (hyperlight_sandbox)

sandbox.register_credential(
    id: str,
    *,
    target: str,
    header: str = "Authorization",
    prefix: str = "Bearer ",
    resolver: Callable[[], str],   # invoked per request, must be fast + thread-safe
)

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 a credential=
kwarg on the existing http_get / http_post wrappers) handles the WIT
attach call 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.

Backend Registry plumbed attach honoured Header injected
Wasm (Wasm) yes yes yes
JavaScript accepted, ignored (parameter prefixed _) no no

SDK coverage

An SDK is a language-side binding that exposes
Sandbox::register_credential (and the resolver shape) to host
application code. All SDKs target the Rust core API.

SDK register_credential exposed Resolver kind Notes
Rust core (hyperlight_sandbox) yes ResolverFn callback + with_static_resolver helper Native API; everything else binds to this.
Python yes Python Callable[[], str] Exceptions are forwarded as the exception type name only — the message body is dropped before crossing the boundary.
.NET no No FFI binding yet — see Out of scope for the delegate-marshalling design considerations.

Build & CI

  • .cargo/config.toml at the repo root sets WIT_WORLD for every
    workspace cargo invocation. Without it hyperlight_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. just recipes already exported it; this covers bare cargo and
    IDE invocations. A previous, redundant .cargo/config.toml under
    src/wasm_sandbox/ that shadowed the workspace one is also removed.
  • CI: "Check WIT artifact is in sync with .wit source" regenerates
    sandbox-world.wasm from hyperlight-sandbox.wit and fails the build
    on git diff. The compiled artifact is consumed by transitive deps
    before 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)

  • Async resolver path. The callback is currently sync 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.
  • Built-in resolver implementations. No IMDS / Key Vault / OAuth
    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.
  • JavaScript backend integration. The JS backend accepts the
    registry parameter but ignores it; attach is not honoured on the JS
    dispatch path. Needs the same wiring as the Wasm backend.
  • .NET SDK binding for register_credential. Python is done; .NET
    has 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 .NET
    exception thrown from the delegate back to Rust as the redacted
    Err(String) — without letting the exception message ride across the
    boundary. A constrained first cut (FFI accepting only a static token,
    mapped onto CredentialEntry::with_static_resolver) is feasible in
    isolation, 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.
  • Stdout/stderr canary sweep. resolver_failure_surfaces_as_error
    asserts the failure diagnostic does not leak, and
    isolated_registries_across_sandboxes proves 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 to
    assert the sentinel string is absent from every guest-visible
    output.

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>
@simongdavies simongdavies added the enhancement New feature or request label May 14, 2026
@jsturtevant
Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants