Goal: an agent USES an API key without ever SEEING it. Intakes that need a
credential (web search, GitHub, Zulip, …) ask for it the normal way — (env/get "BRAVE_API_KEY") — but in the sandbox that returns an opaque placeholder
(@@secret:BRAVE_API_KEY@@). The real value is substituted in only at HTTP
egress, bound to a specific destination, and scrubbed back out of the response.
Key exfiltration is made structurally impossible; the agent can still use the
key against its bound API (the "use-not-read" residual, see below).
This works because dvergr already owns the only egress function the agent can
reach (dvergr.sandbox.ns.io/do-request) plus a domain policy + SSRF guard — so
substitution happens in-process, just before the bytes leave the trusted fn,
with no TLS-MITM proxy, CA, or sidecar.
The host holds a secret registry (build-secret-registry, closed over in
add-env-ns!/add-http-ns! — never an SCI value). Each entry:
{"BRAVE_API_KEY"
{:value "real-key" ; resolved host-side (see sources)
:placeholder "@@secret:BRAVE_API_KEY@@"
:allowed-domains #{"https://api.search.brave.com"}
:allowed-locations #{:header :query} ; :body is default-deny
:header-names #{"X-Subscription-Token"}}}
env/get returns the placeholder, not the value. (Non-secret :sandbox-env
entries — e.g. an endpoint URL — still return their real string.)do-request, after audit → domain-policy → SSRF-guard,
before the HTTP call: for each placeholder found in the request, resolve its
secret, assert the URL is in :allowed-domains (origin-anchored) and the slot
is in :allowed-locations (+ :header-names), then replace it with :value
in that slot only. Any assertion failure throws — never strip-and-send.:value reflected in the response body/headers is
re-masked to its placeholder before returning to SCI. Because the agent never
holds plaintext, a single scrub at this one egress point is complete.Add a :secrets vector to config.local.edn (see config.example.edn for the
full commented set). Each entry binds a credential to its destination + slot. The
value source is one of:
| Source | Meaning |
|---|---|
:env "VAR" | host environment variable |
:value "…" | literal |
:config-path [:k :path] | reuse a value already in the config (e.g. [:github :token]) |
:basic-auth-config-paths [[user-path] [pass-path]] | HTTP Basic — pre-encodes base64(user:pass) so the placeholder stands for the whole Authorization credential |
:secrets [{:name "BRAVE_API_KEY" :env "BRAVE_API_KEY"
:allowed-domains ["https://api.search.brave.com"]
:allowed-locations [:header :query] :header-names ["X-Subscription-Token"]}
{:name "GITHUB_TOKEN" :config-path [:github :token]
:allowed-domains ["https://api.github.com"]
:allowed-locations [:header] :header-names ["Authorization"]}
{:name "ZULIP_AUTH" :basic-auth-config-paths [[:zulip :email] [:zulip :api-key]]
:allowed-domains ["https://your-org.zulipchat.com"]
:allowed-locations [:header] :header-names ["Authorization"]}]
;; non-secret endpoints/identifiers the sandbox needs verbatim:
:sandbox-env {"ZULIP_SITE" "https://your-org.zulipchat.com"}
An entry whose source resolves to nothing (unset env var, missing config) is
silently skipped — the intake simply won't run. :basic-auth intakes read a single
pre-encoded credential ((str "Basic " (env/get "ZULIP_AUTH"))) rather than
base64-ing the key themselves, which would defeat literal-placeholder substitution.
Implementation: dvergr.sandbox.ns.io (registry + substitute + scrub),
dvergr.substrate.config/secret-specs (config resolution),
dvergr.sandbox/setup-agent-namespaces! (wiring). Tests:
test/dvergr/sandbox/secret_injection_test.clj.
The agent can still use the key against the bound API — cost abuse, exfiltrating
data through an allowed call, abusing write scopes. Mitigations (least-privilege
keys at issuance are the first line) and a hardening pass left for later: per-secret
RPM/daily quota enforced at substitution, an outbound tripwire alarming on a
raw value headed to a non-bound host, :allowed-methods per destination, disabling
HTTP auto-redirect for secret-bearing requests (or re-running domain+SSRF per hop),
and human approval for write/destructive bound calls.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |