Credential broker (agent egress proxy)
Anything an agent can read, an agent can be talked into handing over. A credential broker answers that by never giving it the credential in the first place: the workload holds a placeholder that names a secret without being one, and the real value is substituted at the network boundary, on the far side of anything the workload can observe. The 2026 Internet-Draft Credential Broker for Agents (CB4A) describes the same shape — a policy decision point separated from a credential delivery point, injecting on egress. (It's an individual submission with no standing in the IETF process, but it's the clearest write-up of the pattern.)
seekrit-proxy is seekrit's implementation of it, and it 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…
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
- Startup (fail-closed). The proxy reads
SEEKRIT_TOKEN, callsGET /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. - Per request. It matches the request path to a configured route, then
substitutes
{{seekrit:NAME}}in the request path, headers, and body. - 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
403and never forwarded. The destination's address is checked too — see where it will connect. - Per response. Anything the upstream echoes back is scrubbed on the way out — see the response, on the way back. The scan is incremental, so streaming APIs (SSE) work unchanged.
- Audit. Each substitution logs the secret names, method, path, and upstream host — never the values. So does each scrub.
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:
| Preset | Upstream | Secret |
|---|---|---|
openai | api.openai.com | OPENAI_API_KEY |
anthropic | api.anthropic.com | ANTHROPIC_API_KEY |
gemini | generativelanguage.googleapis.com | GEMINI_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:
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
| 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 — multi-arch, static musl on scratch. |
cargo build --release | From source, in apps/proxy. |
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.
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
| 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.
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.
The response, on the way back
Substituting on the way out closes the front door. The back door is the response: plenty of APIs hand the credential you just sent them straight back.
Invalid API Key provided: sk_live_51H8xR2KjW…
That is Stripe's real error body. An OAuth handler will bounce a bearer token
into a Location: redirect. Any SDK's verbose 4xx quotes the request it made,
headers included. Every one of those lands in the agent's context window — the
exact place the placeholder existed to keep it out of.
So the proxy scans responses for the values it injected and replaces them:
{"error":{"message":"Invalid API Key provided: [redacted by seekrit]"}}
Both planes do it, headers as well as bodies, and it is on by default — there is nothing to add to your config.
Streaming still streams. The scan is incremental and holds back only bytes that could still turn out to be the start of a credential, which for ordinary content is none at all — so SSE and token-by-token completions arrive with no added latency and no buffering. A request that injected nothing is not scanned.
What it matches
The exact value, its percent-encoded form (either hex case, for the OAuth
redirect case), and its JSON-string-escaped form. Values shorter than 8 bytes are
never matched: a secret whose value is 8080 would otherwise rewrite every port
number in every response.
It deliberately does not chase masked echoes like sk_live_51ABC****. That
is not a usable credential, and matching it takes a heuristic that will
eventually corrupt a legitimate payload.
Redirects are returned, not followed
The proxy hands a 3xx back to the caller rather than chasing it, the way
nginx's proxy_pass does. Following one would re-send the substituted
credential to a method and path your policy never authorized — the operation
constraints are checked once, against the request your workload actually made.
Tuning it
[redaction]
# enabled = false # opt out entirely
# scan = "all" # every resolved secret, not just this request's own
# placeholder = "[redacted by seekrit]"
# min_length = 8 # never match values shorter than this
# encodings = ["percent", "json"] # naming any opts out of the rest
scan = "all" is for an upstream that can return other credentials — a
provider's own key-listing endpoint, say. It is strictly stronger and
proportionally more scanning; the default covers what this proxy is responsible
for having put in flight.
Watching for it
A scrub is a security event in its own right — it means an upstream is handing a
credential back toward your workload. It writes an audit line naming the secret
(never the value) and increments seekrit.proxy.redactions, tagged by upstream.
A non-zero rate there is worth an alert.
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}}'
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.
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.
Where it will connect
The allowlist bounds names. Names are not destinations:
evil.test. A 169.254.169.254
A rule permitting evil.test is a route to the cloud instance-metadata service
the moment that name resolves there — and on most infrastructure that endpoint
hands out role credentials. This is SSRF, and a proxy whose destination an
untrusted agent chooses is the textbook place for it.
So every outbound connection is also checked on its address. Private,
loopback, link-local, carrier-grade NAT, multicast and reserved ranges are
refused, IPv4 and IPv6, including the mapped forms (::ffff:169.254.169.254 is
the metadata service wearing a hat). On by default.
seekrit-proxy: 169.254.169.254 resolves to 169.254.169.254, which is
link-local (cloud instance metadata) — refusing to connect. If this
destination is intended, add its range to [egress] allow_cidr
It runs at four points, because there are four routes to the same place: a
literal address in an absolute-form URL (which consults no resolver at all), a
literal CONNECT target, a hostname that resolves to a blocked address, and the
far end of a blind tunnel. A tunnel is never decrypted — that is not the same
as never checked.
DNS rebinding is closed by construction. The gate is installed as the HTTP client's own resolver, so the addresses it approves are the addresses dialled. There is no second lookup in between for a rebind to land in.
Your own upstreams still work
A [[route]] upstream is exempt, automatically. SSRF means the attacker
picks the destination; a reverse-proxy upstream is written in your config, by
you. A sidecar on 127.0.0.1, an internal API on 10.x, a docker compose
service name — all unaffected.
[forward.host] rules are deliberately not exempt. A host rule says an agent
may name that host; the name is still resolved at the agent's request.
Reaching an internal destination on purpose
[egress]
allow_cidr = ["10.0.0.0/8", "192.168.1.5/32"]
# block_private_ips = false # turn the check off entirely
Ranges are parsed at startup, so a typo fails before the proxy serves anything rather than at 3am. A bare address means that single host.
Redirects
The proxy returns a 3xx rather than following it, the way nginx's
proxy_pass does — see
redirects are returned, not followed.
Chasing one is how a destination check gets bypassed: the address you approved
is not the address the redirect names.
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.
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, andpathson 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. That's what makes central publishing useful: adding an upstream must not mean editing TOML on every host. - 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:
[[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.
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. An agent the orchestrator spawned inherits the environment, so a token alone does not establish that, and the honest fix is attesting the caller.
Who may use the control listener
The control listener mints tickets and, with [approval], releases held
requests. Both were gated by one thing: knowing SEEKRIT_PROXY_CONTROL_TOKEN.
That gate has a hole shaped like this product. The orchestrator exports the token; the agent it launches is a child process and inherits the environment. So the agent can mint itself a ticket for any identity this proxy serves — and approve its own held request, which is the one thing that control exists to prevent.
A token answers "does the caller know a secret". The question that actually separates an orchestrator from the agent it spawned — same user, same machine, often the same shell — is which program is calling.
[control]
socket = "/run/seekrit/control.sock" # not `listen`: see below
ttl = "1h"
max_ttl = "12h"
# Only this program may mint tickets and decide held requests.
allow_binary = ["/usr/local/bin/your-orchestrator"]
# allow_sha256 = ["9f86d081…"] # pin the image instead of the path
# allow_uid = [501] # defaults to the proxy's own uid
Every connection is checked once, before any request is read — peer
credentials belong to the socket, not to an HTTP request — and a refused one gets
a 403 saying which program was rejected rather than a bare reset.
seekrit-proxy: /usr/local/bin/agent is not an allow-listed program for this control listener
The token is still required. Being the right program does not excuse a missing token, any more than holding the token excuses being the wrong program.
Why a socket, not a port
A TCP connection carries no peer credentials — loopback or not — so a TCP control
listener is reachable by every process on the machine and can only ever check the
token. Naming allow_binary, allow_sha256 or allow_uid without a socket is
a startup error rather than a rule that quietly enforces nothing.
Setting socket also means you get only the socket. Opening a TCP port beside
it would hand back exactly what the socket was chosen to avoid; ask for both
explicitly if you want both, and startup will warn about the TCP half.
The socket is created 0600, so the filesystem is the first gate and attestation
is the second.
What this proves, and what it does not
Worth being precise, because the difference decides how much to lean on it:
| uid / gid | Proven. The kernel attaches them at connect time. Stops a different user; stops nothing else, since an agent runs as the same user as its orchestrator. |
| pid | Proven at connect time. What it names can drift — a peer that exits immediately frees its pid for reuse. Microseconds wide. |
| the program | Inferred. On Linux /proc/<pid>/exe follows the running image's inode, so a binary replaced on disk after exec is still hashed as what is executing. On macOS the path is re-read, so a replacement between exec and inspection would go unnoticed. |
None of it is a signature check. What it buys is real and bounded: local impersonation goes from "read an environment variable" to "be the allow-listed program, or replace it on disk".
Windows named pipes expose an equivalent and are not implemented yet, so socket
is refused there rather than silently degrading to the token alone.
Deciding held requests over a socket
seekrit proxy approvals and friends take a path as well as a URL:
seekrit proxy approvals --control /run/seekrit/control.sock
seekrit proxy approve apr_00000003 --control /run/seekrit/control.sock
seekrit proxy init --control /run/seekrit/control.sock generates the block,
with the allow_binary line commented in place — it is deployment-specific
enough that it has to be written deliberately.
Runs dispatched through seekrit
A [control] ticket is minted on this machine and known only to this proxy. When
a run talks to more than one enforcement point — a proxy and seekrit run, or a
proxy and an SDK shim — you want one dispatch honoured by all of them. That is a
task:
[tasks]
cache_ttl = "30s" # how long an introspected task is reused
TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
The run presents $TASK in the same x-seekrit-ticket header. The proxy
tells the two apart by prefix — skp_ it minted itself, skd_ came from the API
— so adopting tasks costs an existing deployment nothing, and a proxy with no
[tasks] block refuses an skd_ token by name rather than treating it as an
unknown ticket.
Three things worth knowing before you turn it on:
- It is opt-in because it changes your availability posture. Policy has a last-known-good cache; a task does not, because a task's whole meaning is is this still live, which only the API can answer. If seekrit is unreachable, a new run is refused rather than admitted.
cache_ttlis the revocation window.seekrit agents revoketakes effect within it. It is capped at five minutes for that reason.- The local file still decides which identities exist here. A task naming an
agent outside
[policy] agentsis refused by this proxy, not by the API — the same rule tickets follow, and the reason a dispatched task cannot point your deployment at policy you never named.
Reporting decisions back
Policy is easier to keep honest when you can see what it actually authorized:
[activity]
flush_interval = "60s" # one request per interval, none when nothing happened
max_cells = 500 # distinct dimension combinations held between flushes
That feeds seekrit agents review,
which compares the published policy against observed traffic and proposes
narrowings backed by counts.
What leaves your network is deliberately small: hosts, methods, secret names, which rule decided, and how often. No request paths — a path can carry a customer identifier, and shipping thousands of them would open a second data path out of a component built to close the first. No values, ever. Full per-request detail keeps going to your own OTLP collector, which is where a forensic trail belongs.
Off unless configured, and a flush that cannot reach seekrit drops that window rather than retrying: a review missing an hour is not worth a growing queue, and a proxied request must never wait on our API.
A proxy fronting several agents reports under its default identity — per-identity attribution for a multi-agent proxy is a follow-up. On the forward (intercepting) plane the rule index is not reported either, so a review sees the host and method there but not which rule matched.
Stop for a human
Policy and the trust ratchet both answer in microseconds, from rules written earlier. Some operations do not want an answer written earlier. Moving money, deleting a production dataset, sending mail as the company — for those the useful question is not "was this permitted in general" but "is this the one you meant, right now".
[approval]
timeout = "2m" # no answer is a refusal
[[approval.require]]
host = "api.stripe.com"
methods = ["POST"]
paths = ["/v1/charges/**"]
label = "money movement" # what the prompt calls it
A matching request stops in the proxy. Attached to a terminal, you get:
seekrit-proxy: approval needed — money movement
apr_00000003 POST https://api.stripe.com/v1/charges
carries: STRIPE_SECRET_KEY
refused automatically in 120s
[y]es / [n]o / [a]lways ›
Answer y and the request resumes. Answer n and it is refused with 403.
Answer a and the proxy stops asking about that host and method for the rest of
the run.
Where the hold sits
At the last moment before dispatch — after policy, after the ratchet, after substitution. That placement is what lets the prompt name the credential about to travel, which is the detail that makes a yes/no mean anything. Nothing has left the process at that point, and a refusal drops the substituted bytes along with the request.
No answer is a refusal. A request that waits long enough must not become a
permitted one just because a socket was patient. That is also why timeout is
short by default: this is a held connection, and the client on the other end
has timeouts of its own.
From another terminal, or from a container
A proxy in a container has no terminal, so decisions arrive over the
[control] listener instead:
export SEEKRIT_PROXY_CONTROL_TOKEN=… # the same value the proxy was started with
seekrit proxy approvals
seekrit proxy approve apr_00000003
The agent cannot approve its own request. The pending id is never sent to
the workload, and the control listener — loopback-bound, token-authenticated —
is not something the agent can reach. A proxy with neither a terminal nor a
[control] block refuses to start with [approval] configured, rather than
holding every matching request until it times out with nobody able to answer.
What always remembers
One (rule, host, method) triple, in memory, for this process only. Not the path: "always allow POSTs to this host under this rule" is a sentence you can hold in your head, while "always allow anything that looked like that prompt" is not. A restart forgets it, which is the right default for a control whose whole point is a human in the loop.
The trust ratchet
Policy says what an agent may ever do. It has no memory, so it cannot say what a run may do given what it has already touched — which becomes the question that matters once an agent has read something sensitive.
The ratchet adds that memory. Declare a state and what moves a run into it:
[[ratchet.state]]
name = "restricted"
hosts = ["ledger.internal"] # all that is still reachable
secrets = ["LEDGER_TOKEN"] # all that may still be injected
[[ratchet.transition]]
host = "reports.internal"
paths = ["/exports/**"]
to = "restricted"
label = "customer export"
Reading reports.internal/exports/… now costs the run every other host. The next
request to a withdrawn one is refused with something an author can act on:
seekrit-proxy: hooks.slack.com was withdrawn from this run 4s ago by "customer
export" — the trust ratchet is in state "restricted" and does not permit it.
Capability comes back in a new run, not this one.
Four properties, each a deliberate choice:
- Triggers are declared, never inferred. The proxy does not classify response bodies to decide what was sensitive — that would mean inspecting the plaintext this whole design exists to avoid handling, and would make your security boundary a heuristic. You name the host and paths.
- It only turns one way. States are an ordered list and a transition resolves
to an index; advancing takes the later of the two. There is no configuration
that walks a run back, and a
to = "baseline"transition is a startup error. - It only subtracts. The ratchet is an overlay applied after policy, so it can take a host or a secret away and can never add one. That is what makes it safe to read from a local file when signed policy is not.
- State is per run and in memory. A restart clears it, like tickets. Without a ticket or a task there is one implicit session — fine for a single-agent sidecar, but it means a narrowed sidecar stays narrowed until you restart it.
The transition fires when the protected request is authorized, not when its response comes back. Narrowing early is the safe direction, and it is the only thing one process can honestly promise: this proxy streams responses, so "withhold the response until every enforcement point acknowledges" is not something it can guarantee on behalf of a fleet. What it does guarantee is that no request authorized after the protected one sees the wider state.
A ratchet is a fleet control, like the ceiling. On a developer machine it mostly fires mid-loop and gets switched off, which is worse than not having it — the place it earns its keep is a sidecar running unattended work.
Two limits of the developer-machine story
This mode's security claim is weaker than the fleet one, for two reasons.
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 and to GitHub
Container Registry as ghcr.io/seekritdev/proxy (same bytes, same tags; prefer
ghcr.io from CI, which has no anonymous pull rate limit) — 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.
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.
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.