Skip to content

feat(net): credential injection primitives — inject an auth secret in the proxy (RFC #66)#127

Open
dzerik wants to merge 2 commits into
multikernel:mainfrom
dzerik:feat/credential-injection
Open

feat(net): credential injection primitives — inject an auth secret in the proxy (RFC #66)#127
dzerik wants to merge 2 commits into
multikernel:mainfrom
dzerik:feat/credential-injection

Conversation

@dzerik

@dzerik dzerik commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Implements RFC #66, Phase 1, Layer 2 (transparent mode). This is the bottom-up primitive slice — the generic mechanism, not the --service openai presets (those are a later slice).

What it does

A named secret lives only in the supervisor. When the sandboxed child makes a request to a matching upstream, the secret is rendered into an auth header (or query parameter) inside the MITM proxy — after http_acl_check, so a denied request never touches the secret — and the child never sees the real value.

sandlock run \
  --http-allow "* api.openai.com/*" --http-inject-ca \
  --credential openai=env:OPENAI_API_KEY \
  --http-inject "* api.openai.com/* bearer openai" \
  -- agent ...

Design

  • Secret containment. SecretString is zeroed on drop (volatile writes), has a redacted Debug, and deliberately implements no Display/ToString/serde::Serialize and is unreachable through any Error chain — so a stray format!("{}", ..) or a serialized policy can't re-open the leak the type exists to close. It flows to the proxy as Arc<Vec<InjectRule>> and is #[serde(skip)] on the sandbox (re-loaded from source per build, never persisted).
  • ACL before inject. AclService::handle applies the first matching rule only after the ACL passes; the deny path returns before any rendering. Audit records the credential name, never the value.
  • OnExistingHeader defaults to Replace. The proxy owns the credential, and every major SDK requires an API key to be set and always sends its own placeholder Authorization. Keeping it would forward the placeholder and never inject — so the rule's target header is replaced by default; pass a trailing add-only to keep a child-set value instead.
  • Sources. env: / file: / fd: (a trailing newline is stripped); literal: is rejected (leaks via ps / shell history). file:/fd: are capped at 64 KiB; fd: refuses the std streams and reads through a dup so the caller's fd stays open. Env-sourced secrets are stripped from the child's environment post-fork, so the agent can't read the value straight out of its own env.

Safety rails

  • --http-inject without an ACL proxy is rejected; without a CA it warns that only plaintext HTTP is intercepted (a rule for an HTTPS host would silently no-op otherwise).
  • A secret that can't render into a header (e.g. contains CRLF) fails the request rather than forwarding it uncredentialed.
  • A checkpoint is rejected while injection is configured, since the non-serialized secrets can't be restored into the image.

Auth shapes

bearer · basic:<user> (base64) · header:<name> / apikey:<name> · query:<param> (raw bytes percent-encoded, so a binary key isn't corrupted).

Tests

  • Unit: SecretString redaction, source loading (incl. literal:/std-fd rejection and the 64 KiB cap), every AuthShape, the Replace default vs add-only, query AddOnly de-dup, a CRLF secret failing the request, and resolve_inject_rules Arc de-dup + env-strip collection.
  • Integration: a credential is injected into the upstream while the child carries none; a denied request never renders the secret (403, upstream never contacted); an env: credential is scrubbed from the child.
  • HTTPS/MITM injection runs the same scheme-agnostic apply path; a TLS end-to-end test needs a CA-trust harness and is tracked as a follow-up (noted in the test file).

Deferred to later slices

--service openai (Layer 1) presets, phantom-token env swap, explicit proxy mode, body-level injection, and a structured audit log (the current audit is a by-name stderr line).

… the proxy (RFC multikernel#66)

Phase 1, Layer 2 (transparent mode): a named secret lives only in the
supervisor and is rendered into a matching request's auth header (or
query parameter) inside the MITM proxy — after the ACL check, so a denied
request never touches the secret — while the sandboxed child never sees
the real value.

New `credential` module:
  - `SecretString`: zeroed on drop (volatile writes), redacted `Debug`, and
    deliberately no `Display`/`ToString`/`Serialize` and unreachable through
    any `Error` chain, so a stray `format!("{}", ..)` can't re-open the leak.
  - Sources `env:`/`file:`/`fd:` (a trailing newline is stripped); `literal:`
    is rejected — it leaks via ps / shell history. `file:`/`fd:` are capped at
    64 KiB and `fd:` refuses the std streams (0/1/2) and reads through a dup.
  - `AuthShape` renderings: `bearer`, `basic:<user>` (base64), `header:<name>`
    / `apikey:<name>`, `query:<param>`. Injected headers are marked sensitive;
    a secret that can't render (e.g. CRLF) fails the request rather than
    forwarding it uncredentialed.
  - `OnExistingHeader` defaults to `Replace`: the proxy owns the credential,
    and SDKs (openai, anthropic, …) always send a placeholder `Authorization`,
    so keeping it would forward the placeholder and never inject. Pass the
    trailing `add-only` token to keep a value the child set instead.

Wiring: `--credential NAME=SOURCE` and `--http-inject "METHOD HOST/PATH
AUTHSPEC CREDNAME [replace|add-only]"` flow through the builder to a resolved
`Arc<Vec<InjectRule>>` on the sandbox (never serialized — `#[serde(skip)]`;
secrets are re-loaded from source per build, not persisted). `AclService`
applies the first matching rule to the outbound request after `http_acl_check`
and records the credential *name* — never its value. `--http-inject` requires
an HTTP ACL proxy (rejected otherwise); with no CA it warns that only
plaintext HTTP is intercepted. A checkpoint is rejected while injection is
configured (the non-serialized secrets can't be restored into the image).

Env-sourced secrets are stripped from the child's environment post-fork, so
the agent can't read the real value straight out of its own env instead of
relying on proxy-side injection.

Deferred to later slices: `--service openai` (Layer 1), phantom-token env
swap, explicit proxy mode, body-level injection, and a structured audit log
(the current audit is a by-name stderr line).

Tests: unit coverage of `SecretString` redaction, source loading (incl.
`literal:`/std-fd rejection and the 64 KiB cap), every `AuthShape`, the
`Replace` default vs `add-only`, query encoding, a CRLF secret failing the
request, and rule parsing incl. Arc dedup + env-strip collection; integration
tests that a credential is injected into the upstream while the child carries
none, that a denied request never renders the secret, and that an `env:`
credential is scrubbed from the child. HTTPS/MITM injection is exercised by
the same scheme-agnostic path but a TLS end-to-end test is a follow-up.
@dzerik dzerik force-pushed the feat/credential-injection branch from c8db97b to a0eeac8 Compare July 6, 2026 12:00

@congwang-mk congwang-mk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design looks solid overall: ACL-before-inject is structural, SecretString containment is well closed, and reusing HttpRule keeps one matching semantics. Four inline findings below. Two smaller notes not worth inline threads: (1) with a CA configured, a rule matching a port-80 host still injects into cleartext with no warning (the build-time warning only fires when no CA is set); (2) refusing fd:0 blocks the common stdin secret pattern (docker/systemd pipes), consider allowing it or documenting the <(...) workaround.

// Remove env vars whose value was loaded into the supervisor as a credential
// source, so the agent can't read the real secret straight from its own
// environment (this is the child; the mutation is process-local and post-fork).
for name in &sandbox.inject_env_strip {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This strip runs after the sandbox.env set loop, so it also deletes an explicitly passed placeholder (--env OPENAI_API_KEY=dummy). SDKs refuse to start without the var set, which is exactly the placeholder flow the Replace default is designed for, so the canonical env: workflow breaks before any request is made. Strip the inherited env first, then apply sandbox.env: the inherited real secret is still removed and deliberate user config is honored.

let uri = &parts.uri;
let path = uri.path();
let existing = uri.query();
// Honor AddOnly: don't append a param the request already carries.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace does not replace for the query shape: the existing param is only checked under AddOnly, so the default path appends a duplicate (key=child&key=sk-real, as the test pins). Most frameworks read the first occurrence, so the upstream authenticates against the child's placeholder and returns 401; it is also parameter-pollution ambiguity. On Replace, filter out existing param= pairs before appending, mirroring headers.insert.

// `apikey:<header>` and `header:<name>` are the same rendering.
("header" | "apikey", Some(name)) if !name.is_empty() => AuthShape::Header { name: name.to_string() },
("query", Some(param)) if !param.is_empty() => AuthShape::Query { param: param.to_string() },
_ => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A username containing : is accepted here (basic:a:b yields username a:b), which shifts the credential boundary in the encoded user:pass: the upstream sees user a, password b:<secret>. RFC 7617 forbids : in the user-id; reject it at parse time.

pub fn parse_auth(spec: &str, credential: &str) -> Result<AuthShape, SandboxError> {
let (kind, arg) = match spec.split_once(':') {
Some((k, a)) => (k, Some(a)),
None => (spec, None),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale doc: says it returns (shape, credential_name) but the function returns only the AuthShape.

- context.rs: strip inherited credential env vars *before* applying
  `sandbox.env`, so a deliberate placeholder (`--env OPENAI_API_KEY=dummy`,
  which SDKs need set to start) survives while the inherited real secret is
  still removed. Previously the strip ran after and deleted the placeholder,
  breaking the canonical `env:` flow before any request.
- credential.rs: `Replace` now actually replaces for the query shape —
  existing occurrences of the target param are dropped before appending,
  instead of producing a duplicate (`key=child&key=sk-real`) where a framework
  reading the first occurrence would authenticate against the child's value.
  The test is corrected to assert replacement, not the duplicate it pinned.
- credential.rs: reject ':' in a `basic:<user>` user-id (RFC 7617) — it would
  shift the `user:pass` boundary and leak part of the secret into the password.
- credential.rs: drop the stale `parse_auth` doc line claiming a tuple return.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants