# Agent egress proxy

`seekrit-proxy` keeps decrypted secrets **out of a workload's memory**. Where
[`seekrit-run`](/docs/guides/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](/docs/concepts/access-control) — 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.

> **Warning:** 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:

```bash
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:

```bash
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:

| Preset | Upstream | Secret |
| --- | --- | --- |
| `openai` | `api.openai.com` | `OPENAI_API_KEY` |
| `anthropic` | `api.anthropic.com` | `ANTHROPIC_API_KEY` |
| `openrouter` | `openrouter.ai` | `OPENROUTER_API_KEY` |
| `github` | `api.github.com` | `GITHUB_TOKEN` |
| `openai-compatible` | your `--base-url` | `OPENAI_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:

```bash
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](#forward-proxy-transparent-via-https_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

| Install | When |
| --- | --- |
| `npx -y @seekrit/proxy` | The 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 \| sh` | A 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/proxy` | [A container](#run-in-a-container) — multi-arch, static musl on `scratch`. |
| `cargo build --release` | From 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:

```toml
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:

```bash
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.

```toml
[[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](/docs/guides/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:

```bash
# 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

| Flag | Default | Description |
| --- | --- | --- |
| `-c, --config <path>` | `./seekrit-proxy.toml` | Route + allowlist config. |
| `--listen <addr>` | from config (`127.0.0.1:8080`) | Override the listen address. |
| `-t, --token <skt_…>` | `SEEKRIT_TOKEN` | Service token. |
| `--api-url <url>` | `SEEKRIT_API_URL` or `https://api.seekrit.dev` | API base URL. |

Availability options live in the config file's `[cache]` block — see
[Starting during a seekrit outage](#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](/docs/guides/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:

```toml
[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](/docs/reference/cli#last-known-good-cache) 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.

```toml
[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:

```bash
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.

> **Warning:** 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](/docs/guides/agent-proxy/policy) in the dashboard, and
shrinks the file to a **trust anchor**:

```toml
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.

> **Warning:** `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

| Situation | Behaviour |
| --- | --- |
| Policy fetch fails at startup | Refuse to start — or last-known-good, if `[cache]` is on |
| Policy fetch fails on refresh | Keep the policy already in force until it expires, then fail closed |
| Signature invalid, or signer not pinned | Refuse the bundle. Never fall back to an unsigned or older one |
| Bundle expired | Every 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 dashboard | The 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:

```toml
[[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:

```toml
[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:

```bash
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`.

> **Warning:** 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:

```bash
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:

```toml
# 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"]
```

```bash
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:

```bash
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.

> **Warning:** A sidecar is what makes the trust boundary real, and it is where the [developer-machine caveat](#two-honest-limits-of-the-developer-machine-story) 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](/docs/guides/service-tokens) for how to mint the token the
proxy needs.
