Proxy routing rules
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]. |
substituteIn | Where the secret may be substituted: header (default), header:<name>, query, query:<param>, body:<path>, e.g. substituteIn=[header, "body:client_secret"] (see Substitution surface). |
maxOccurrences | How many times the placeholder may appear in one request before it’s blocked (default 1) (see Substitution surface). |
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, block, substituteIn, and maxOccurrences, 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.)
Substitution surface
Section titled “Substitution surface”Matching a rule decides which host a secret may go to. Two more guards decide where inside the request the placeholder gets swapped for the real value, and how many times. They exist because the proxy substitutes by finding the placeholder in the outbound bytes: without limits, an agent that was prompt-injected could place the placeholder somewhere the real value then leaks. The classic case is a request to an allowed host that forwards the value onward, e.g. asking a mail API to send an email whose body contains the placeholder.
substituteIn, where the swap may happen. By default a secret is only substituted into request headers (any header). That covers the common case, since most APIs authenticate with an Authorization or X-Api-Key header. If the placeholder shows up anywhere a target doesn’t allow, the request is blocked rather than substituted, so the real value never lands somewhere it could be exfiltrated. Targets can be as broad or as specific as you want:
| Target | Allows substitution in |
|---|---|
header | any request header value (the default) |
header:authorization | only the named header (case-insensitive) |
path | anywhere in the URL path, for APIs that carry a token in the path (/v1/{token}/data) |
query | anywhere in the query string |
query:api_key | only the named query parameter’s value |
body:client_secret | only the value at that body path (see below) |
body:* | anywhere in the body (escape hatch for unparseable bodies, see below) |
Pin as tightly as the API allows: header:authorization blocks the secret being swapped into any other header (some providers forward custom headers onward), and a body path blocks it landing in any other field.
The bare header default still excludes a handful of headers that are never a legitimate secret and are common forward/log sinks: cookie, host, x-forwarded-*, forwarded, via, referer, origin, and user-agent. A placeholder landing in one of those is blocked even under the any-header default. If an API genuinely authenticates through one (a session cookie, say), name it explicitly with substituteIn=[header:cookie] and the explicit target wins.
# OAuth token exchange carries the secret in a form field:# @proxy(domain="api.example.com", path="/oauth/token", substituteIn=[header, "body:client_secret"])CLIENT_SECRET=yourPreferredPlugin()Body substitution always requires a path. There is no bare body target: substituteIn=[body] is a schema error. This is deliberate. “Anywhere in the body” is the easiest surface to exfiltrate from (the email-body attack above), and maxOccurrences alone doesn’t close it: the placeholder placed once in the wrong field still passes the count check. Naming the path (body:client_secret) is what actually pins the secret to the field it belongs in.
A body path is a dotted path into a JSON body (client_secret, data.token, items[0].key) or a field name in an application/x-www-form-urlencoded body. The content type selects the parser; a body that can’t be parsed as declared fails closed.
For a body format varlock can’t parse into a path (XML/SOAP, protobuf, plain text, a signed blob), use the wildcard body:*. It allows the placeholder anywhere in the body, so it reopens the “anywhere in the body” surface: only reach for it when a path won’t work, scope the rule tightly with path and method to the one endpoint that needs it, and keep maxOccurrences low. Don’t use it on an endpoint that echoes, forwards, or stores body content (a mail-send or note-create endpoint), where it would let a secret leak.
maxOccurrences, how many copies. A valid request uses a secret a fixed number of times (almost always once). By default the placeholder may appear at most once per request; a second copy is treated as an exfiltration attempt (duplicate the token into an attacker-visible field while still making a working call) and the request is blocked. Raise it only for an API that legitimately repeats the same secret:
# @proxy(domain="api.example.com", substituteIn=["header:authorization", "body:signature"], maxOccurrences=2)SIGNING_KEY=yourPreferredPlugin()Both guards fail closed and, like a route mismatch, produce a message naming the item, where it was found, and how to widen the rule if the placement is legitimate.
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()Reference
Section titled “Reference”@proxyConfigand@proxyroot decorators@proxyitem decoratorvarlock proxyCLI commands- Sandboxing
- AI Tools guide