| title | Proxy routing rules |
|---|---|
| description | Configure @proxy routing rules, placeholders, and what the agent sees |
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). |
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()
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, and substituteIn, but not domain or keys (those stay on the parent).
- 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=[...].
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.)
:::note[Helpful failures instead of mystery 401s]
Even in permissive mode, if a request carries a placeholder that no rule injects on that route (e.g. the agent hits /v2/… but your rule matched /v1/…), the proxy blocks it with a message naming the item and the rule gap — rather than forwarding the placeholder and letting the upstream reject it with a confusing 401. So a mismatched path tells you the proxy rule is the problem, not "check your credentials."
:::
Matching a rule decides which host a secret may go to. substituteIn decides where inside the request the placeholder is swapped for the real value, and each place you name is worth one swap. Without that, a prompt-injected agent could put the placeholder somewhere the real value then leaks: the classic case is asking a mail API on an allowed host to send an email whose body contains it.
By default a secret is only substituted into request headers (any header), which covers most APIs. 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 keeps the secret out of every other header (some providers forward custom ones onward), and a body path pins it to one field.
The bare header default excludes headers that are never a legitimate secret and are common forward/log sinks: cookie, host, x-forwarded-*, forwarded, via, referer, origin, and user-agent. If an API really authenticates through one, name it explicitly (substituteIn=[header:cookie]) and the explicit target wins.
Placeholders outside your targets are left alone. An occurrence in a part of the request no target covers (the body, under the header-only default) is skipped: those bytes are never rewritten, and the request is forwarded with the literal placeholder, which is inert. This is routine with agents, which quote their own env var into the conversation transcript they send with every call. Every item with a skipped placeholder gets one skipped-placeholder audit event per request, naming the item and the parts of the request its placeholder turned up in, so probing stays visible.
Body substitution always requires a path. substituteIn=[body] is a schema error, deliberately: "anywhere in the body" is the easiest surface to exfiltrate from, and a placeholder placed once in the wrong field would pass any count check. A 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, and a body that can't be parsed as declared fails closed.
# 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()
For a body varlock can't parse into a path (XML/SOAP, protobuf, plain text, a signed blob), body:* allows the placeholder anywhere in it. That reopens the surface a path exists to close, so scope the rule tightly with path and method, and don't use it on an endpoint that echoes, forwards, or stores body content.
One substitution per target. A second occurrence at the same target is blocked, since the proxy can't tell the real use from an exfiltration copy. Skipped occurrences belong to no target, so they never count against it. Note that the bare header target is a single target covering every header, so the default allows the secret in one header, not one per header. An API that carries it in two places just names both, which tightens the rule rather than loosening it:
# One substitution in the auth header, one in the body's signature field:
# @proxy(domain="api.example.com", substituteIn=["header:authorization", "body:signature"])
SIGNING_KEY=yourPreferredPlugin()
Two things still fail closed, and both return a 403 naming the item, where the placeholder was found, and how to adjust the rule:
- A repeat at one target, as above.
- An occurrence off the named spot inside a targeted body or query. With a
body:<path>orquery:<param>target, substitution is one find-and-replace across that whole surface, so a stray occurrence elsewhere in it can't be skipped without rewriting the body.
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.
:::note[varlock treats items as sensitive by default]
Unless an item is marked @sensitive=false (shorthand: @public), or made non-sensitive by a @type / @defaultSensitive rule, varlock considers it sensitive, so it becomes a placeholder in the proxied child. If your agent needs to read a non-secret config value (an API base URL, a feature flag), mark it @sensitive=false so it passes through with its real value.
:::
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-resolved
UNUSED_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.
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_00000000000000000000000000
STRIPE_SECRET_KEY=yourPreferredPlugin()
@proxyConfigand@proxyroot decorators@proxyitem decoratorvarlock proxyCLI commands- Sandboxing
- AI Tools guide