Skip to content

Latest commit

 

History

History
168 lines (120 loc) · 12.7 KB

File metadata and controls

168 lines (120 loc) · 12.7 KB
title Proxy routing rules
description Configure @proxy routing rules, placeholders, and what the agent sees

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

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

  • Attached rule: @proxy on an item. Injects that item's secret into matching requests. (Attach extra items with the keys array: @proxy(domain="api.x.com", keys=[OTHER_KEY]).)
  • Detached rule: @proxy in the header. A policy-only rule (match or block) for a domain. It injects nothing on its own, but can inject named items with keys=[...].

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 @proxy rules 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." :::

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> or query:<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.

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 @sensitive item 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.

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:

  1. An explicit @placeholder value (always wins).
  2. A valid-and-unique value derived from the item's @type: e.g. @type=urlhttps://vlk-placeholder-…invalid/, @type=email / uuid / md5 likewise, and @type=string(startsWith=sk-, isLength=20) yields an sk--shaped placeholder.
  3. 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()

Reference