seekrit
Docs/Agent egress proxy

Agent egress proxy

seekrit-proxy keeps decrypted secrets out of a workload's memory. Where seekrit-run injects plaintext into a process's environment — fine when you trust that process with its own keys — the proxy is for workloads you don't fully trust, like an AI agent that runs arbitrary tools. The workload sends requests with placeholders; the proxy swaps in the real, decrypted value on the way to the upstream. The plaintext only ever exists inside the proxy and in the request to an allowlisted host.

  agent ──▶  Authorization: Bearer {{seekrit:EXAMPLE_API_KEY}}
                    │  (localhost / sidecar — never sees the real key)
             seekrit-proxy   ── resolves + decrypts once at startup
                    │            substitutes, checks the allowlist, audits
                    ▼
             api.example.com   Authorization: Bearer …real key…
note

The proxy holds a service-token grant, so it is "just another principal" in the key-grant model — the same mechanism the CLI, the browser, and seekrit-run use. Its decryption runs through the exact same zero-knowledge path; the API never sees plaintext.

Why not just use seekrit-run?

seekrit-run puts secrets in the process environment, so the process can read them — and anything the process can read, it can exfiltrate. For an untrusted or agentic workload that's the whole problem. The proxy moves the plaintext to a separate trust boundary: the agent holds only {{seekrit:NAME}}, and a real credential appears only in the request to the allowlisted upstream.

How it works

  1. Startup (fail-closed). The proxy reads SEEKRIT_TOKEN, calls GET /v1/resolve, and decrypts every granted secret into memory. If the token is missing/bad, the API is unreachable, or a layer won't decrypt, it refuses to start — a security control should never silently forward unsubstituted placeholders.
  2. Per request. It matches the request path to a configured route, then substitutes {{seekrit:NAME}} in the request path, headers, and body. Only the request is rewritten; the response streams back untouched, so streaming APIs (SSE) work unchanged.
  3. Allowlist (default-deny). Each route declares which secrets may be injected toward its upstream. A placeholder naming a secret not on that list — or one that didn't resolve — is refused with 403 and never forwarded.
  4. Audit. Each substitution logs the secret names, method, path, and upstream host — never the values.
caution

The allowlist is the security boundary. Without it, a proxy that injects any secret into any request is an exfiltration oracle: an agent could point it at an attacker's host and have a real key filled in. Each route only injects the secrets you list for its upstream.

Try it in one command

The proxy is a compiled binary, but you do not need a Rust toolchain — or a config file — to see it work. With Node available:

export SEEKRIT_TOKEN=skt_…
npx -y @seekrit/cli proxy run --preset openai

That fetches the released binary for your platform (verifying its SHA-256), generates a config for api.openai.com, and starts serving. It writes nothing to your project. Point a workload at it:

export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1
export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}'

seekrit proxy presets lists what ships:

PresetUpstreamSecret
openaiapi.openai.comOPENAI_API_KEY
anthropicapi.anthropic.comANTHROPIC_API_KEY
openrouteropenrouter.aiOPENROUTER_API_KEY
githubapi.github.comGITHUB_TOKEN
openai-compatibleyour --base-urlOPENAI_API_KEY

Anything not listed works the same way — --host api.example.com=EXAMPLE_API_KEY — and presets are combinable: --preset openai --preset anthropic --host api.example.com=EXAMPLE_API_KEY.

A preset is not a shortcut for something hard; the TOML it stands for is four lines. It exists because those four lines need three things that are easy to get subtly wrong — the bare hostname a rule matches, the path shape the provider's API uses, and whether that provider's SDK expects its base URL to already include /v1. (The OpenAI SDKs do; the Anthropic SDKs do not, and append /v1/messages themselves.) Get the last one wrong and the upstream returns a 404 that reads like a proxy bug.

Keep the config instead

run is for trying it. When you want a file to review, commit, and tighten, ask for one:

seekrit proxy init --preset openai --preset anthropic

It writes a commented seekrit-proxy.toml — hostnames, secret names, and the methods/paths bounds each preset ships, plus the environment variables your workload needs, as comments. There are no secret values in it, so it is safe to commit. Add --print to review it first, or --mode forward for the forward-proxy shape.

Presets ship the narrowest methods/paths that still let each provider's SDK work, which makes the generated file obvious to tighten further. They are a starting point, not a decision about what your agent should reach — a generator does not know your threat model.

Other ways to install

InstallWhen
npx -y @seekrit/proxyThe binary, npx-able. Interprets no flags of its own — --help is the proxy's. Its package version is the binary version it fetches, so @seekrit/proxy@0.7.0 runs proxy 0.7.0.
curl -fsSL https://proxy.seekrit.dev/install.sh | shA binary on your PATH, for a container image, a CI runner, or a systemd unit where Node is not in the picture.
docker run seekritdev/proxyA container — multi-arch, static musl on scratch.
cargo build --releaseFrom source, in apps/proxy.
note

Every path above lands on the same released binary and verifies the same published SHA-256. That is integrity, not provenance: it proves the bytes match what the release published, exactly what install.sh gives you and no more.

Configure

Create seekrit-proxy.toml. Each [[route]] maps a path prefix to an upstream and the secrets it may receive:

listen = "127.0.0.1:8080"

[[route]]
prefix = "/example"
upstream = "https://api.example.com"
allow = ["EXAMPLE_API_KEY"]

The service token comes from the environment, never the (committable) config:

export SEEKRIT_TOKEN=skt_…
seekrit-proxy --config seekrit-proxy.toml

Bound what the agent may do, not just which key it may use

A rule can also name the methods and paths it permits. Both are optional and both default to "any", so an existing config keeps behaving exactly as it did — but adding them turns the allowlist from anti-theft into anti-misuse: an agent holding a legitimate credential still cannot reach an operation you did not grant it.

[[route]]
prefix = "/openai"
upstream = "https://api.openai.com"
methods = ["POST"]
paths = ["/v1/chat/completions", "/v1/embeddings"]
allow = ["OPENAI_API_KEY"]

# A second rule for the same upstream: reads are fine, but carry no credential.
[[route]]
prefix = "/openai-ro"
upstream = "https://api.openai.com"
methods = ["GET"]
paths = ["/v1/models", "/v1/models/*"]

Path patterns are matched segment-wise against the upstream-facing path (the request path with the route prefix stripped): * matches within one segment and ** matches any number of them, so /v1/** covers everything under /v1 including /v1 itself. Matching is case-sensitive and the query string never participates — a rule permits an operation, and ? parameters are not one.

note

Empty means opposite things in different fields, and the asymmetry is deliberate: no methods and no paths mean any (the constraint is opt-in), while no allow means no secret may be injected (that one has always been default-deny). A rule with no allow is how you permit an operation without letting a credential travel with it.

A request no rule covers is refused with 403, and the message names the constraint that decided — the method, the path, or the absence of any rule for that host — because a default-deny policy otherwise fails in exactly the confusing direction.

Point your workload at it

For the exact argument to change in a specific framework — LangGraph, Mastra, Pydantic AI, the AI SDK, CrewAI, LlamaIndex, or either Agents SDK — see AI frameworks, which has a copy-paste route and model configuration per framework. The general shape, in any language:

Set the workload's base URL to the matching route prefix and pass the credential as a placeholder. Most SDKs take both from the environment:

# Any OpenAI-compatible SDK, for example — nothing here is provider-specific.
export OPENAI_BASE_URL=http://127.0.0.1:8080/example
export OPENAI_API_KEY='{{seekrit:EXAMPLE_API_KEY}}'

A request to http://127.0.0.1:8080/example/v1/models is forwarded to https://api.example.com/v1/models with EXAMPLE_API_KEY substituted into the Authorization header. The agent process only ever held the placeholder.

Options

FlagDefaultDescription
-c, --config <path>./seekrit-proxy.tomlRoute + allowlist config.
--listen <addr>from config (127.0.0.1:8080)Override the listen address.
-t, --token <skt_…>SEEKRIT_TOKENService token.
--api-url <url>SEEKRIT_API_URL or https://api.seekrit.devAPI base URL.

Availability options live in the config file's [cache] block — see Starting during a seekrit outage.

Placeholder format

Placeholders are {{seekrit:NAME}}, where NAME matches a resolved secret ([A-Za-z0-9_]+). Anything that isn't a well-formed, terminated placeholder is left verbatim, so ordinary request content passes through untouched. A proxy with no matching placeholders is a plain pass-through.

Secrets are resolved once at startup, with ${OTHER_SECRET} references already expanded — a placeholder always substitutes the finished value.

Starting during a seekrit outage

The proxy is a security control, so it fails closed: if it cannot resolve at startup it refuses to run, rather than forwarding requests with placeholders left intact. To let it start on the last response it saw instead, add a [cache] block:

[cache]
enabled = true
max_age = "24h"                  # how stale that copy may be (default: 24h)
# dir = "/var/cache/seekrit"
# reconnect_interval = "5s"      # first retry after a degraded start
# reconnect_max_interval = "5m"  # backoff ceiling

Only the encrypted response is stored; decrypting it still requires the proxy's service token. A proxy that started this way logs loudly, keeps retrying, and swaps in live secrets the moment the API answers — so the stale window is as short as the network allows. A refused resolve (401/403) never falls back: the entry is deleted and the proxy still fails closed. See the CLI reference for the full trade-off.

Forward proxy (transparent, via HTTPS_PROXY)

The reverse-proxy model needs the workload to point each SDK's base URL at the proxy. That's fine for one or two known APIs, but an agent that calls many hosts (an LLM and GitHub and some tool and whatever it decides to curl) is better served by the forward-proxy model: the workload sets one environment variable and every egress flows through the proxy, no per-SDK wiring.

[forward]
listen = "127.0.0.1:8081"
unmatched_host_policy = "tunnel"   # or "deny"
ca_cert = "seekrit-proxy-ca.pem"
ca_key  = "seekrit-proxy-ca-key.pem"

[[forward.host]]
match = "api.example.com"
allow = ["EXAMPLE_API_KEY"]

For a ruled host the proxy answers the client's CONNECT, terminates TLS with a leaf certificate it mints for that host (signed by a local CA it generates once and persists), reads the plaintext request, substitutes, and re-originates a real TLS request to the upstream. Point the workload at it:

export HTTPS_PROXY=http://127.0.0.1:8081
# Trust the proxy's CA (pick what your runtime reads):
export NODE_EXTRA_CA_CERTS=$PWD/seekrit-proxy-ca.pem   # Node
# export SSL_CERT_FILE=$PWD/seekrit-proxy-ca.pem       # OpenSSL/curl
# export REQUESTS_CA_BUNDLE=$PWD/seekrit-proxy-ca.pem  # Python requests
export ANTHROPIC_API_KEY='{{seekrit:EXAMPLE_API_KEY}}'
note

The proxy only intercepts hosts that have a rule. A host with no rule is blind-tunneled through untouched (tunnel, the default) so the agent's other traffic keeps working, or refused with 403 (deny) for a strict allowlist of reachable hosts. It never reads or injects into traffic it has no rule for.

caution

TLS interception means the workload must trust the proxy's CA. Its private key stays on the proxy host and only ever signs leaves for hosts you list. Install the CA only into the trust store of the workload you're proxying — not system-wide on a shared machine.

Take the rules from the dashboard instead

Everything above lives in a file, which is the right default: authorization with no network dependency, and a file seekrit cannot change. What it costs is that every new upstream is a redeploy — awkward for a fleet, and absurd for a developer whose coding agent just hit a tool it has no credential for.

Server policy mode moves the churny half — the rules and the credentials — into agent access policy in the dashboard, and shrinks the file to a trust anchor:

listen = "127.0.0.1:8080"

[[route]]                        # routing stays local: prefix → upstream
prefix = "/openai"
upstream = "https://api.openai.com"

[policy]
source = "server"                # "file" (the default) keeps today's behaviour
agent = "nova"                   # the agent identity this deployment is
refresh_interval = "10s"         # how soon a published change lands here
signers = ["kNc8…thumbprint"]    # ← the trust anchor. Copy it from the dashboard.

seekrit proxy init --agent nova writes exactly that file: it reads the policy through the same route the proxy polls, derives one [[route]] per host in it, and pins the thumbprints it found — each annotated with where it came from, because that distinction is the whole question for this field (see the warning below).

The bundle the API serves is signed in the publishing admin's browser with their own P-256 key, and this proxy refuses any bundle not signed by a key whose thumbprint is in signers. So seekrit can withhold your policy — the proxy then fails closed — but it cannot widen it, which is the property that lets policy live in a dashboard at all. The dashboard's trust-anchor panel prints the exact snippet, thumbprints included.

caution

signers must come from the local file, not from us. A proxy with source = "server" and no pinned signer refuses to start, on purpose: the alternative is a proxy that trusts whatever the API says about where your credentials may go.

This is also the one field seekrit proxy init --agent cannot fully generate. A thumbprint it read back from the API inherits that API's trust until a human confirms it, so the generated file labels each one — your own signing key (derived locally) versus read from the policy the API served. VERIFY THIS. Check the second kind against the dashboard's trust-anchor panel before you rely on the file.

Two more things change in server mode, both because a rule is useless without the credential it names:

  • Rules cannot also be authored locally. allow, methods, and paths on a [[route]] or [[forward.host]] are rejected rather than silently ignored, and in forward mode the set of intercepted hosts comes from the published policy — which is the whole point, since adding an upstream must not mean editing TOML.
  • Secrets are re-resolved on the same interval. The proxy historically resolved once and never again, so a credential added in the dashboard would never reach a healthy running proxy. In server mode it re-resolves on the policy interval; under file policy you can opt in with [secrets] refresh_interval = "30s". No new grant is involved — a new secret in an environment this proxy already has a key grant for decrypts with the key it already holds.

Failure semantics

SituationBehaviour
Policy fetch fails at startupRefuse to start — or last-known-good, if [cache] is on
Policy fetch fails on refreshKeep the policy already in force until it expires, then fail closed
Signature invalid, or signer not pinnedRefuse the bundle. Never fall back to an unsigned or older one
Bundle expiredEvery injection refused, with expiry named as the reason
Bundle exceeds a local [[policy.ceiling]]Refuse the bundle wholesale — a narrowed policy nobody authored should not run
Agent identity disabled in the dashboardThe next refresh is refused; the current bundle's expiry bounds the window

An optional ceiling, for fleets

A fleet deployment can also state the hosts and secret names that are ever permissible here, which server policy may only narrow:

[[policy.ceiling]]
host = "api.openai.com"
allow = ["OPENAI_API_KEY"]

It closes the "stolen admin signing key" case, and an existing file config converts into one unchanged. It is off by default and wrong for interactive development, where the adversary is the local agent — something with shell access can edit any local file, so a ceiling adds no security there and puts every new upstream back into a TOML edit.

Several agents behind one proxy

One proxy often fronts several agents that should not have the same reach — the case a file cannot express, because a file has no way to tell two callers apart. A [control] listener closes that without handing an agent any authority:

[control]
listen = "127.0.0.1:9090"
ttl = "1h"                       # default ticket lifetime
max_ttl = "12h"

[policy]
source = "server"
agent = "nova"                   # the default identity, for unticketed requests
agents = ["nova", "scribe"]      # every identity this proxy may serve
signers = ["kNc8…thumbprint"]

The orchestrator — trusted, and the thing that knows which agent it is starting — mints a ticket and hands it to the agent:

export SEEKRIT_PROXY_CONTROL_TOKEN=$(openssl rand -hex 32)   # before starting the proxy

curl -s localhost:9090/session \
  -H "x-seekrit-control-token: $SEEKRIT_PROXY_CONTROL_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"agent":"scribe","scopes":["GITHUB_TOKEN"],"ttl":"15m"}'
# → {"ticket":"skp_…","agent":"scribe","expires_in_seconds":900,"header":"x-seekrit-ticket"}

The agent presents that ticket in x-seekrit-ticket on its requests; the proxy strips the header before forwarding upstream. Scopes can only ever narrow — the effective set is the ticket's ∩ the published policy's — and an unknown or expired ticket is refused rather than quietly treated as unticketed. A single-agent sidecar never has to adopt any of this: with no [control] block there is no listener, and requests are evaluated against [policy] agent.

caution

The control token is required, and must not be readable by the agent. Without it any local process — including the agent the proxy exists to constrain — could mint itself a ticket for any identity.

Two honest limits of the developer-machine story

Both are worth stating plainly, because this mode's security claim is weaker than the fleet one and should not be oversold.

The agent must not be able to read SEEKRIT_TOKEN. If a coding agent with shell access can read the proxy's token, it can call /v1/resolve itself and skip the proxy entirely — and then policy is decoration. Run the proxy as a separate OS user, or in a container, so the token lives somewhere the agent cannot reach. On a single-user laptop this is the genuinely hard part.

HTTPS_PROXY is not an enforcement mechanism. An agent that can unset an environment variable is not confined by it. Real confinement needs a container network namespace or a firewall rule that makes the proxy the only route out.

Run in a container

The proxy is published to Docker Hub as seekritdev/proxy — multi-arch, a single static musl binary on scratch (no OS, no shell), so the runtime image is nothing but the proxy holding your secrets in memory. latest and <version> tags are cut on release; edge tracks main. Mount a config, pass the token in the environment, and publish the port:

docker run --rm -e SEEKRIT_TOKEN=skt_… \
  -v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \
  -p 8080:8080 seekritdev/proxy --listen 0.0.0.0:8080

The image reads its config from /seekrit-proxy.toml (override with --config) and the token from SEEKRIT_TOKEN. Which address to bind depends on how the container is reached:

  • Sidecar sharing the workload's network namespace — keep the default loopback bind (127.0.0.1), so nothing outside the pod can reach the proxy.
  • Standalone container — pass --listen 0.0.0.0:<port> (as above) so the workload's container can connect, and publish only to that container's network.
note

In forward mode, persist the CA across restarts by mounting a volume for the ca_cert / ca_key paths. Otherwise the proxy generates a fresh CA on each start and the certificate the workload trusts stops matching.

Point the config's ca_cert / ca_key at a mounted directory, then mount both the config and that directory:

# seekrit-proxy.toml
[forward]
listen = "0.0.0.0:8081"
ca_cert = "/ca/seekrit-proxy-ca.pem"
ca_key  = "/ca/seekrit-proxy-ca-key.pem"

[[forward.host]]
match = "api.example.com"
allow = ["EXAMPLE_API_KEY"]
mkdir -p ca   # first run writes the CA here; later runs reuse it
docker run --rm -e SEEKRIT_TOKEN=skt_… \
  -v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \
  -v "$PWD/ca:/ca" \
  -p 8081:8081 seekritdev/proxy

The generated ca/seekrit-proxy-ca.pem is the cert the workload trusts (via NODE_EXTRA_CA_CERTS and friends, above); because it lives on the mounted volume it survives container restarts.

A docker compose sidecar

seekrit proxy compose prints one for a config you describe with the same flags init takes:

seekrit proxy compose --preset openai > compose.proxy.yaml

The container case differs from the local one in exactly the ways that break a copied-from-the-docs compose file, and the generated snippet handles all three: the proxy must bind 0.0.0.0 to be reachable from a sibling container, the workload dials it by service name rather than loopback, and in forward mode the CA must live on a shared volume or the workload ends up trusting a certificate the proxy no longer has. It publishes no ports by default — reachable on the compose network only, so nothing outside the project can ask the proxy to inject a key.

caution

A sidecar is what makes the trust boundary real, and it is where the developer-machine caveat above stops applying: the service token lives in the proxy's environment, which the workload's container cannot read. For full confinement, put the workload on an internal: true network with the proxy as its only peer — otherwise it can simply ignore the base URL you gave it.

Pin a release tag (e.g. seekritdev/proxy:0.2.0) for reproducible deployments, or :edge for the latest main. To build the image yourself, see apps/proxy/Dockerfile — the shared crypto crate is supplied as a named build context, so build from the repo root.

Both modes can run at once, on different ports, from a single config. See Service tokens for how to mint the token the proxy needs.