Credential proxy
The credential proxy lets you run an AI agent (or any untrusted component) so that it never sees real secrets. Instead it receives placeholders and real values are swapped in at the network boundary.
This is useful any time a process you don’t fully trust needs to use a secret without being allowed to read it: coding agents, MCP servers, third-party CLIs, or scripts.
This pattern is known as a credential broker: a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the SANS Institute and in Anthropic’s managed-agents architecture.
Most other credential brokers require you to store secrets in their tool, or work only with a limited subset of secure storage locations (system keychain, 1Password). Our proxy works with the existing varlock suite - so you can use any of our plugins, and still take advantage of the rest of varlock’s features.
# @sensitive# @proxy(domain="api.stripe.com") @placeholder=sk_test_00000000000000000000000000STRIPE_SECRET_KEY=yourPreferredPlugin() # from a plugin or built-in encryption — never a plaintext secretvarlock proxy run -- claudeThe agent launched above sees STRIPE_SECRET_KEY=sk_test_0000…, a placeholder shaped like a real key. When it makes a request to api.stripe.com, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder.
The @placeholder keeps the placeholder valid-looking so the Stripe SDK’s client-side key-format check passes. See Placeholders for when you need it.
How it works
Section titled “How it works”- You mark which secrets are proxied with
@proxy(domain=...). - The child process is launched with placeholders in place of those secrets, plus the standard proxy/CA environment variables pointing at a local proxy.
- The proxy intercepts HTTPS traffic. For a request that matches a rule, it verifies the upstream’s real TLS identity, then substitutes the placeholder for the real secret only on that connection.
- Responses are scrubbed so real values can’t leak back into the child’s output, and every request is recorded to an audit log.
Secrets and the proxy’s signing key live only in memory on your machine; they are never written to disk and never handed to the child.
Quick start
Section titled “Quick start”This assumes you already have varlock installed and a working .env.schema whose secrets resolve (committed encrypted values, a plugin, or env values you can load).
1. Route a secret through the proxy
Section titled “1. Route a secret through the proxy”Mark the item @sensitive and add @proxy(domain=...) to the item you want to protect. The @proxy tells the proxy to inject the item’s real value into requests to that host:
# @sensitive# @proxy(domain="api.stripe.com")# @placeholder=sk_test_00000000000000000000000000STRIPE_SECRET_KEY=yourPreferredPlugin()
# @sensitive# @proxy(domain="api.github.com")GITHUB_TOKEN=yourPreferredPlugin()@proxy already implies @sensitive (and varlock treats items as sensitive by default), so the @sensitive line is optional. We show it explicitly here because being clear about what is a secret is good practice.
The @placeholder on STRIPE_SECRET_KEY makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like GITHUB_TOKEN above) and the item gets a generic vlk_placeholder_…, which varlock warns about because it can fail a client-side key-format check (see Placeholders).
That is all the proxy needs: there is no separate “enable” step. It runs in permissive mode by default (hosts that don’t match a rule pass through untouched). Add @proxyConfig={egress="strict"} to your schema header only when you want to block everything that isn’t explicitly routed.
2. Check your config (optional)
Section titled “2. Check your config (optional)”varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started3. Run your agent through it
Section titled “3. Run your agent through it”varlock proxy run -- claude# or any command:varlock proxy run -- node agent.jsvarlock proxy run -- python tool.pyThat’s it. The child inherits everything it needs (proxy address + CA trust) automatically.
Routing rules
Section titled “Routing rules”A @proxy(...) rule supports more than just a domain:
| Option | Meaning |
|---|---|
domain | (required) Host to match: a single host or an array list. Supports globs, e.g. *.example.com. |
path | Restrict to matching URL paths (glob), e.g. path="/v1/**". |
method | Restrict to one or more HTTP methods, e.g. method=[GET, POST]. |
block | block=true denies matching requests outright (fail closed). |
keys | Array of additional item names to inject for this rule, e.g. keys=[STRIPE_KEY, WEBHOOK_SECRET]. |
rules | Array of per-path/method policy refinements that share this rule’s domain (see Grouping rules for one domain). |
domain and method take either a single value or an array literal for lists:
# @proxyConfig={egress="strict"}# Block a dangerous endpoint entirely (detached rule, no injection):# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true)# ---# Match either host, any method:# @sensitive# @proxy(domain=[api.stripe.com, api.stripe-test.com])STRIPE_SECRET_KEY=yourPreferredPlugin()Grouping rules for one domain
Section titled “Grouping rules for one domain”When one host needs several path/method policies, write the domain once and list the refinements under rules:
# @proxyConfig={egress="strict"}# ---# @sensitive# @proxy(domain="api.stripe.com", rules=[# {path="/v1/refunds/**", method=[POST, DELETE], block=true},# {path="/v1/payouts/**", block=true},# ])STRIPE_SECRET_KEY=yourPreferredPlugin()This injects STRIPE_SECRET_KEY across api.stripe.com and blocks refunds and payouts. The parent @proxy(...) still controls injection (where the secret goes); each rules entry is a policy-only refinement that inherits the domain and injects nothing on its own, so precedence (block over allow) does the rest. An entry may set path, method, and block, but not domain or keys (those stay on the parent).
Attached vs detached rules
Section titled “Attached vs detached rules”- Attached rule:
@proxyon an item. Injects that item’s secret into matching requests. (Attach extra items with thekeysarray:@proxy(domain="api.x.com", keys=[OTHER_KEY]).) - Detached rule:
@proxyin the header. A policy-only rule (match orblock) for a domain. It injects nothing on its own, but can inject named items withkeys=[...].
Egress modes
Section titled “Egress modes”The @proxyConfig={egress=...} header decorator controls what happens to requests that don’t match any rule. It’s the only reason to write @proxyConfig at all: the proxy works without it, and egress defaults to permissive.
permissive(default, no decorator needed): an unmatched request passes through untouched (no injection, no blocking). Good for getting started.strict: add@proxyConfig={egress="strict"}to the header so only requests that match an allow (@proxy) rule are allowed; everything else is blocked — including a request to a host that has@proxyrules but none matching this path/method. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts (or arbitrary endpoints on a routed host).
A matching block rule always wins over an allow rule, in either egress mode — so a block=true rule denies a request even if a broader @proxy allow rule also matches it. (To allow only a subset of a host and deny the rest, use strict egress with a specific allow rule, rather than a broad block with a narrow allow.)
Controlling what the agent sees
Section titled “Controlling what the agent sees”By default, varlock applies least privilege to the proxied child:
- A
@proxy(domain=...)item → the agent sees a placeholder; the real value is injected at the wire. - A
@sensitiveitem with no proxy policy → the agent sees a placeholder too (it just isn’t injected anywhere). The real value never reaches the child. - Non-sensitive items → passed through normally.
Because every sensitive item resolves to a placeholder inside a proxied session, an agent can’t trivially recover a secret by re-running varlock load / varlock printenv from within the proxied session; it gets the same placeholder back, not the real value. (A determined agent on the same machine can still escape this; see Limitations and pair the proxy with a sandbox for a real boundary.)
To override the default for an item, use the value form of @proxy:
# @sensitive# @proxy=passthrough # inject the REAL value into the child (escape hatch)LEGACY_TOKEN=yourPreferredPlugin()
# @proxy=omit # withhold entirely: absent from the child env, and # resolves to "unset" (not the real value) if re-resolvedUNUSED_SECRET=yourPreferredPlugin()@proxy=passthrough and @proxy=omit are the value form of the decorator and cannot be combined with the function form (@proxy(...)) on the same item.
Placeholders
Section titled “Placeholders”You don’t have to define placeholders. If you don’t set one, varlock generates a placeholder for every proxied item automatically. Its exact value usually doesn’t matter, because the proxy injects the real secret on the wire regardless of what the placeholder looks like. It matters only when the client checks the key’s format locally, before sending — typically an SDK (for example the OpenAI or Stripe client asserting an sk- / sk_ prefix when you construct it). A raw HTTP client (curl, fetch, and most tools) accepts any placeholder, so the generated one is fine.
The placeholder the agent sees is chosen in priority order:
- An explicit
@placeholdervalue (always wins). - A valid-and-unique value derived from the item’s
@type: e.g.@type=url→https://vlk-placeholder-…invalid/,@type=email/uuid/md5likewise, and@type=string(startsWith=sk-, isLength=20)yields ansk--shaped placeholder. - A generic fallback (
vlk_placeholder_<KEY>_…). For a@proxy-routed item varlock warns about this one, since it’s the case that can fail an SDK’s format check; if your client doesn’t validate the format, it’s harmless.
Every placeholder is unique per item, so two different secrets can never collide on the wire.
If an SDK rejects the generic placeholder, add an @placeholder or a typed format so it looks valid to the client:
# @sensitive# @proxy(domain="api.stripe.com")# @placeholder=sk_test_00000000000000000000000000STRIPE_SECRET_KEY=yourPreferredPlugin()Running modes
Section titled “Running modes”There are three ways to drive the proxy, trading convenience for interactivity.
One-shot: proxy run
Section titled “One-shot: proxy run”varlock proxy run -- <command>Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a proxy start daemon is already running for this directory, proxy run attaches to it (so its terminal owns the live request log); otherwise it runs a self-contained proxy. Pass --new to force a fresh, separate proxy.
Daemon + attach: proxy start
Section titled “Daemon + attach: proxy start”# Terminal 1: owns the proxy and its live request logvarlock proxy start
# Terminal 2: attaches automaticallyvarlock proxy run -- claudeproxy start is a long-lived session that owns its terminal, where the live per-request log appears. Attach to it from another terminal (or shell) so the agent’s stdio doesn’t compete with the daemon’s output.
An attaching proxy run adopts the running session’s env: it fetches the child view (placeholders, non-secret values, omitted keys) directly from the daemon instead of resolving anything itself. That means no second unlock prompt, and the session’s own overrides and env selection apply (attaching means “run me in that session’s env”). Your shell’s ambient values for schema-managed keys are ignored in favor of the session’s; everything else (PATH, etc.) passes through as usual.
Manual env: proxy start + proxy env
Section titled “Manual env: proxy start + proxy env”varlock proxy start # in one terminaleval "$(varlock proxy env)" # in another shellnode agent.js # now routed through the proxyproxy env prints the proxy + CA environment for an existing session so you can source it into any shell or tool.
Sessions
Section titled “Sessions”Every varlock proxy command operates on a session: one running proxy with its own short id (printed by proxy start, listed by proxy status). You target a session in one of two ways:
- By id: pass
--session <id>to any command (proxy env --session abc12,proxy stop --session abc12, etc.). - Auto-discovered: with no
--session, the command resolves one for you:proxy runattaches to the runningproxy startdaemon for the current directory (a session whose directory is this one or a parent of it). If there isn’t one it starts its own proxy; pass--newto always start a fresh, separate one.env/status/audit/reload/stopuse the single active session. If more than one is running they ask you to pass--session <id>; commands run from inside a proxied child target that child’s own session automatically.
Sessions are durable records: they persist after the proxy stops (visible with proxy status --all) so their audit log stays available.
How the child is wired
Section titled “How the child is wired”proxy run (and proxy env) inject a standard set of environment variables into the child so common HTTP clients trust the proxy with no manual setup:
- Proxy address:
HTTP_PROXY/HTTPS_PROXY/ALL_PROXY(and lowercase), withNO_PROXYexcluding localhost. - CA trust:
NODE_EXTRA_CA_CERTS(Node.js),REQUESTS_CA_BUNDLE(Pythonrequests),CURL_CA_BUNDLE(curl),GIT_SSL_CAINFO(git), andSSL_CERT_FILE(OpenSSL-based tools).
The proxy uses an ephemeral, in-memory certificate authority generated per run; only its public certificate is written to a temp file for the child to trust. The CA private key and all per-host certificates never leave memory.
Stdout/stderr redaction is decided per stream: a stream attached to an interactive terminal passes through raw (so TUIs like claude render correctly), while a piped or redirected stream is scrubbed of sensitive values, including the real values the proxy injects at the wire, in case an upstream response echoes one back. Override with --redact-stdout / --no-redact-stdout.
Pinning the port and CA location
Section titled “Pinning the port and CA location”By default the proxy binds a random loopback port and writes its CA cert to a fresh temp dir. Both are discoverable after startup with proxy env / proxy status. When something needs to be wired up before the proxy starts (a config that points at a fixed HTTP_PROXY, or a tool told to trust a CA at a known path), pin them:
varlock proxy start --port 8080 --cert-dir ./.varlock-proxy--port <n> fixes the loopback port, so HTTP_PROXY is http://127.0.0.1:<n>. If that port is already in use the proxy refuses to start instead of falling back to a random one.
--cert-dir <dir> writes ca-cert.pem (and combined-ca.pem, the CA plus system roots) into <dir>, creating it if missing. The CA is still ephemeral and regenerated on each start; on stop, only the cert files varlock wrote are removed, never the directory itself.
Both flags apply only when starting a proxy. They also work on proxy run when it starts its own proxy (with --new, or when no daemon is running for the directory). On a proxy run that attaches to an existing daemon they are rejected, since that daemon already fixed the port and cert location.
Auditing
Section titled “Auditing”Every request through the proxy is appended to a per-session, secrets-free audit log (host, method, path, a request hash, the matched rule, the decision, and which key names were injected, never any values).
varlock proxy audit # current/most-recent sessionvarlock proxy audit --session <id> # a specific sessionvarlock proxy audit --format json # machine-readableSessions are kept as durable records after they end. List them with:
varlock proxy status # active sessionsvarlock proxy status --all # include ended sessionsvarlock proxy status --watch # live updatesEditing the schema while a session is running
Section titled “Editing the schema while a session is running”For safety, a proxied command refuses to run if your env schema has changed since the proxy session started. This prevents an agent from editing the schema mid-session (downgrading @sensitive, or adding a new item that resolves a secret) to recover real values. After an intentional edit, restart the proxy from a trusted (non-proxied) shell so it re-resolves the new schema:
varlock proxy stop --allvarlock proxy start # or: varlock proxy run -- <command>A one-shot varlock proxy run already re-reads the schema on its next invocation, so just re-run it.
A running varlock proxy start also warns in its live log when it detects your env schema changed on disk (any of the loaded .env* files, not just .env.schema). It keeps serving the previous policy (it does not block your agent’s in-flight requests just because a file changed), so reload or restart to apply the change, and if you did not make the edit, a proxied process may have.
Sandboxing
Section titled “Sandboxing”On its own the proxy raises the bar but is not a hard boundary: a determined same-uid agent can still reach a secret’s source. Close that with --sandbox so the child’s only network path is the proxy:
varlock proxy run --sandbox -- claudeBare --sandbox is a built-in macOS jail (loopback-only egress + credential-path and keychain denials). Use --sandbox=docker (or =podman) for a container whose only egress is the host proxy. Details, flags, and third-party recipes: Sandboxing.
Limitations
Section titled “Limitations”The proxy is a local, same-machine tool. Know what it does and doesn’t cover before relying on it:
- Same host, same user. The proxy runs as you, and so does the agent. It stops the agent from trivially reading a secret it’s using, but on its own it is not a sandbox: it doesn’t isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy’s view (e.g. reparented via
setsid) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Add--sandbox(or a container/VM; see Sandboxing) for a real isolation boundary. - Response scrubbing is best-effort. Responses are scrubbed back to placeholders only for small, uncompressed text bodies. Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed: the proxy can’t scan into them. This only matters if an upstream reflects your secret back in its response (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected.
- Injection requires a verified public-TLS host. Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext
http://or into a self-signed/local upstream, by design. - Default egress is not containment.
egress=permissive(the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Useegress=strict(and ideally a sandbox) if you want to constrain egress. - Hot-reload is human-applied.
proxy reload(or pressingrin an interactiveproxy start) re-resolves the schema and swaps the live policy; a reload requested from inside the proxied agent is refused and logged so the agent can’t self-approve a schema edit. Availability follows@proxyConfig={reload=...}(defaultauto: on for an interactive daemon, off for headless or one-shot runs). But the agent-refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, prefer a sandbox. - Only proxy-aware clients are covered. The child is pointed at the proxy via standard
HTTP(S)_PROXY/ CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won’t get a working secret.
Reference
Section titled “Reference”@proxyConfigand@proxyroot decorators@proxyitem decoratorvarlock proxyCLI commands- Sandboxing
- AI Tools guide