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). |
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, and substituteIn, 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. 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.
Request transforms
Section titled “Request transforms”Some APIs never receive the secret at all. Instead, every request carries an HMAC signature computed with it: crypto exchange APIs (Coinbase, Kraken), and many webhook and partner APIs that require an HMAC-SHA256 of the request body in a signature header. Substitution can’t cover these, since the secret never appears in the request, so there is no placeholder to swap.
The transform= option on a @proxy rule makes the proxy compute the credential at the wire instead. This is a stronger boundary than substitution: a substituted credential is a string the child could have held, but here the child cannot produce a valid request even in principle, because it never holds the underlying secret. Signing is the main case, but a transform is any scheme that computes what the request carries: http-basic below composes a header instead of signing.
# @proxy(domain="api.exchange.com", transform={# scheme="hmac-sha256",# stringToSign="{timestamp}{method}{pathWithQuery}{body}",# signatureHeader="X-ACCESS-SIGN",# timestampHeader="X-ACCESS-TIMESTAMP",# keyId=$EXCHANGE_API_KEY, keyHeader="X-ACCESS-KEY",# encoding="hex",# })EXCHANGE_API_SECRET=yourPreferredPlugin()
# @sensitiveEXCHANGE_API_KEY=yourPreferredPlugin()The signature is computed over the final outbound request, after placeholder substitution, so it covers exactly the bytes the upstream receives. Any signature or timestamp headers the child sent are overwritten (an SDK configured with placeholder credentials produces a garbage signature; the proxy replaces it with a valid one).
Each scheme accepts its own option set. scheme is required on every transform: hmac-sha256, hmac-sha512, and http-basic are built in, and plugins add more (e.g. aws-sigv4, see below). Options for the hmac-* schemes:
| Option | Meaning |
|---|---|
stringToSign | (required) Template for the signed message. Fields: {timestamp} {method} {path} {pathWithQuery} {query} {host} {body}. |
signatureHeader | (required) Header the signature is written to. |
secretKey | Reference to the item whose value is the HMAC key, e.g. secretKey=$PARTNER_SECRET. Defaults to the decorated item on an attached rule; required on a detached rule. |
keyId / keyHeader | Optional companion item reference (an API key id, e.g. keyId=$EXCHANGE_API_KEY) and the header it is written to. Set together. |
timestampHeader | Header the signing timestamp is written to. |
encoding | Signature output encoding: base64 (default) or hex. |
keyEncoding | How the secret decodes into key bytes: raw (default), base64, or hex. Some APIs (Coinbase Prime) issue base64-encoded secrets. |
timestampFormat | unix-seconds (default), unix-millis, unix-nanos, or rfc3339. |
A few properties worth knowing:
- The transform credential is consumed, not substituted. The child never holds it: it only ever sees a placeholder, and the proxy applies the real value itself.
substituteIntherefore doesn’t apply to it, and if the placeholder shows up anywhere in any request the request is blocked, since there is no legitimate reason for the child to send it. ThekeyIditem is wire-visible (it is an API key id, not a secret key), so it substitutes normally like akeys=entry. - Whether the secret itself reaches the API depends on the scheme. With
hmac-*andaws-sigv4only a derived signature travels, so the secret never leaves the proxy.http-basicis different: Basic auth carries the credentials themselves, base64-encoded, so they do reach the API. In both cases the guarantee for the agent is the same, and it is the one that matters here: it cannot produce a valid request itself, because it never holds the credential. - The transform rides its rule’s match. A transform applies when a rule carrying
transform=matches the request. For a domain with several rules, put the transform on one broaddomain=-only rule and keeppath/method/block/approvalrefinements on separate rules; two different transform configs matching the same request is a schema misconfiguration and the request fails closed. - TLS only. Like injection, transforms refuse cleartext connections: a credential over plain http is readable in transit, and a signature over it is trivially replayable.
- If the transform-carrying rule requires
approval, the transform runs only after the approval gate passes, mirroring how approval-gated injection works.
HTTP Basic auth
Section titled “HTTP Basic auth”Basic auth defeats plain substitution on its own: the child sends Authorization: Basic base64(user:placeholder), and since the placeholder is base64-encoded, it never appears as a substring the proxy could swap. The built-in http-basic scheme has the proxy write the header itself, with the real values.
The scheme has two options, username and password, one per side of the credential pair. Each references the item holding that side’s value, which the proxy resolves when it applies the transform. On an attached rule the decorated item fills whichever side you leave unset, and if you set neither it is the userid, since a single-credential Basic API almost always sends the token as the userid with an empty password (curl -u "token:").
# token as the userid, empty password# @proxy(domain="api.stripe.com", transform={scheme="http-basic"})STRIPE_SECRET_KEY=yourPreferredPlugin()
# username given, so the item is the password# @proxy(domain="registry.example.com", transform={scheme="http-basic", username=$REGISTRY_USER})REGISTRY_PASSWORD=yourPreferredPlugin()
# @sensitive=falseREGISTRY_USER=ci-botA detached rule names both sides itself:
# @proxy(domain="api.twilio.com", transform={# scheme="http-basic", username=$TWILIO_ACCOUNT_SID, password=$TWILIO_AUTH_TOKEN,# })# ---# @sensitiveTWILIO_ACCOUNT_SID=yourPreferredPlugin()# @sensitiveTWILIO_AUTH_TOKEN=yourPreferredPlugin()Both sides are references, never inline values, including fixed ones: GitHub’s TOKEN:x-oauth-basic pairing is an item holding x-oauth-basic referenced as the password. That keeps one rule for every case (either side secret, both secret, or one fixed), makes each side environment-overridable, and means a username holding sensitive data gets the same protections as the password. Every referenced item is a consumed credential: managed, so the child only ever sees a placeholder, never substituted, and leak-guarded. A rule needs at least one side, so a detached rule naming neither is a schema error.
Because Basic auth carries the credentials in the request (base64-encoded, not hashed), the proxy also treats the encoded Authorization value as a secret in responses: an endpoint that reflects the header cannot hand the child something it could decode. Response scrubbing covers the sensitive values a rule sends to that upstream, and this scheme adds the encoded form on top, since only the scheme can produce it.
Anything computed, like a fixed prefix, is composed in the item rather than in the rule:
# @sensitiveAPI_PASSWORD=prefix-${RAW_SECRET}AWS SigV4 (plugin)
Section titled “AWS SigV4 (plugin)”The aws-sigv4 scheme re-signs AWS SDK requests and ships as a separate plugin, @varlock/aws-sigv4-plugin, so core varlock carries no AWS dependencies. AWS never receives the secret access key either; every request carries a signature computed from it. Configure the SDK in the child with the placeholder credentials (varlock’s proxied env does this automatically) and point it at the proxy; the SDK signs normally, and the proxy strips the placeholder signature and re-signs with the real keys.
# @plugin(@varlock/aws-sigv4-plugin)# ---# @proxy(domain="*.amazonaws.com", transform={# scheme="aws-sigv4", keyId=$AWS_ACCESS_KEY_ID,# allowedServices=[bedrock, s3],# })AWS_SECRET_ACCESS_KEY=somePlugin()
# @sensitiveAWS_ACCESS_KEY_ID=somePlugin()The region and service need no configuration: they are parsed from the inbound request’s credential scope, so one rule covers every AWS service and region the client talks to. The same scheme covers S3-compatible services that authenticate with SigV4 (Cloudflare R2, MinIO, Backblaze B2, and others); point the rule’s domain at their endpoint. See the plugin docs for all options and limitations.
Plugin-provided schemes
Section titled “Plugin-provided schemes”Transform schemes are registered through the plugin system: a plugin declares the scheme’s options (which are validated exactly like the built-in ones, including which options name credential items) and provides the function that runs in the proxy per matching request. This keeps provider-specific code and dependencies out of core. Custom or venue-specific schemes that the generic template can’t express (nested hashing, custom canonicalization) are built the same way, including as local, unpublished plugins (@plugin(./my-transform-plugin)).
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