# 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.

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

## Point your workload at it

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

## 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.

## 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.

## 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.

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.
