You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Carry disallowed-surface placeholder occurrences inert instead of blocking
A matched rule's placeholder appearing in a surface the rule has no
substitution targets on (typically the request body under the header-only
default) previously blocked the whole request with blocked-location. That
bricked 'varlock proxy run -- claude': once the agent quotes its own
placeholder, every subsequent request carries it in the transcript body.
Blocking added no protection since the proxy never substitutes there and an
unswapped placeholder is inert.
Now such occurrences are carried through unsubstituted, the request is
forwarded, and a new carried-placeholder audit event records the item key
and where the placeholder sat. Substitution is scoped per surface (per
header, path, query, body) so carried occurrences stay literal.
Fail-closed blocking is kept where it still protects:
- occurrences at allowed targets exceeding maxOccurrences (the cap now
counts only allowed-target occurrences, not carried ones)
- off-path/off-param occurrences within a body/query surface the rule
targets via body:<path>/query:<param> (blind replace can't skip them)
- the route-mismatch helpful-failure block, unchanged
Proxy: a placeholder appearing in a request surface its rule doesn't substitute in (e.g. the body under the default header-only targets) is now carried through unsubstituted and logged as a carried-placeholder audit event, instead of blocking the request. Blocking still applies to off-path occurrences within body:<path>/query:<param> targets and to the maxOccurrences cap, which now counts only occurrences at allowed targets.
|`keys`| Array of additional item names to inject for this rule, e.g. `keys=[STRIPE_KEY, WEBHOOK_SECRET]`. |
17
17
|`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](#substitution-surface)). |
18
-
|`maxOccurrences`| How many times the placeholder may appear in one request before it's blocked (default `1`) (see [Substitution surface](#substitution-surface)). |
18
+
|`maxOccurrences`| How many times the placeholder may appear at allowed substitution targets in one request before it's blocked (default `1`) (see [Substitution surface](#substitution-surface)). |
19
19
|`rules`| Array of per-path/method policy refinements that share this rule's `domain` (see [Grouping rules for one domain](#grouping-rules-for-one-domain)). |
20
20
21
21
`domain` and `method` take either a single value or an **array literal** for lists:
@@ -70,7 +70,7 @@ Even in `permissive` mode, if a request carries a placeholder that **no rule inj
70
70
71
71
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.
72
72
73
-
**`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:
73
+
**`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 in a part of the request the rule has no targets on (the body, say, under the header-only default), that part is simply never rewritten: the request is forwarded with the placeholder **carried through unsubstituted**, which is harmless because an unswapped placeholder is just an inert string. This happens routinely in agent sessions, for example when an agent echoes its own env var and the placeholder ends up quoted in the conversation transcript it sends with every API call. Each carried occurrence is recorded as a `carried-placeholder` event in the [audit log](/guides/proxy/running/#auditing) (the item key and where it was found), so anything probing at the secret stays visible. Targets can be as broad or as specific as you want:
74
74
75
75
| Target | Allows substitution in |
76
76
|---|---|
@@ -82,9 +82,9 @@ Matching a rule decides **which host** a secret may go to. Two more guards decid
82
82
|`body:client_secret`| only the value at that body path (see below) |
83
83
|`body:*`| anywhere in the body (escape hatch for unparseable bodies, see below) |
84
84
85
-
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.
85
+
Pin as tightly as the API allows: with `header:authorization` the secret is only ever swapped into that one header (some providers forward custom headers onward), and a body path pins it to the one field it belongs in. A placeholder anywhere outside the targets stays an inert placeholder.
86
86
87
-
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.
87
+
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 carried through unsubstituted 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.
88
88
89
89
```env-spec title=".env.schema"
90
90
# OAuth token exchange carries the secret in a form field:
@@ -98,14 +98,19 @@ A body path is a dotted path into a JSON body (`client_secret`, `data.token`, `i
98
98
99
99
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.
100
100
101
-
**`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:
101
+
**`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 at allowed substitution targets; a second copy at an allowed spot 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. Carried occurrences in untargeted surfaces don't count toward the cap, since they're never substituted. Raise the cap only for an API that legitimately repeats the same secret:
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.
108
+
**When the proxy still blocks.** Carrying only applies to surfaces the rule has no targets on. Two cases fail closed:
109
+
110
+
-**Off-path occurrences inside a targeted body or query.** When a rule has a `body:<path>` (or `query:<param>`) target and the placeholder also shows up at a different path or param in that same body or query, the request is blocked. Substitution within a targeted surface is a single find-and-replace across it, so a stray occurrence there would either receive the real value or require rewriting the body to skip it.
111
+
-**The occurrence cap**, as above: too many copies at allowed targets is ambiguous about which copy is the real use, so the request is blocked rather than substituting both.
112
+
113
+
Blocked requests get a `403` naming the item, where the placeholder was found, and how to adjust the rule if the placement is legitimate.
Copy file name to clipboardExpand all lines: packages/varlock-website/src/content/docs/guides/proxy/running.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -116,7 +116,7 @@ These flags apply only when **starting** a proxy. They also work on `proxy run`
116
116
117
117
## Auditing
118
118
119
-
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).
119
+
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). When a placeholder is [carried through unsubstituted](/guides/proxy/rules/#substitution-surface) (it appeared in a surface its rule doesn't substitute in), the log also gets a `carried-placeholder` line naming the item key and where the placeholder sat.
|`approval`|`approval=true` holds matching requests for an interactive yes/no in the `proxy start` terminal before they proceed. A self-contained one-shot `proxy run` has no terminal to prompt in and denies them. |
344
344
|`keys`| Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. |
345
-
|`substituteIn`| Where the placeholder may be swapped for the real value: `header` (default), `header:<name>`, `path`, `query`, `query:<param>`, or `body:<path>`, e.g. `[header, "body:client_secret"]`. Body always requires a path (`body:*` allows anywhere, for bodies that can't be parsed into a path). A placeholder anywhere no target allows blocks the request instead of substituting. See [Substitution surface](/guides/proxy/rules/#substitution-surface). |
346
-
|`maxOccurrences`| Max times the placeholder may appear in one request before it's blocked (default `1`). See [Substitution surface](/guides/proxy/rules/#substitution-surface). |
345
+
|`substituteIn`| Where the placeholder may be swapped for the real value: `header` (default), `header:<name>`, `path`, `query`, `query:<param>`, or `body:<path>`, e.g. `[header, "body:client_secret"]`. Body always requires a path (`body:*` allows anywhere, for bodies that can't be parsed into a path). A placeholder in a surface with no targets is carried through unsubstituted (inert) and audited; one off the named path/param within a targeted body or query blocks the request. See [Substitution surface](/guides/proxy/rules/#substitution-surface). |
346
+
|`maxOccurrences`| Max times the placeholder may appear at allowed substitution targets in one request before it's blocked (default `1`). See [Substitution surface](/guides/proxy/rules/#substitution-surface). |
347
347
|`rules`| Array of policy refinements sharing this rule's `domain`, e.g. `rules=[{path="/v1/**", block=true}]`. Each entry may set `path`/`method`/`block`/`approval`/`substituteIn`/`maxOccurrences` (not `domain`/`keys`) and injects nothing on its own. See the [Grouping rules guide](/guides/proxy/rules/#grouping-rules-for-one-domain). |
348
348
349
349
The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`).
0 commit comments