Skip to content
7 changes: 7 additions & 0 deletions .bumpy/proxy-skip-inert-placeholders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
varlock: minor
---

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 skipped (forwarded unsubstituted) and logged as a skipped-placeholder audit event, instead of blocking the request. Blocking still applies to occurrences off the named path/param within a body:<path> or query:<param> target.

The `maxOccurrences` option has been removed. Each `substituteIn` target is now worth one substitution per request, so listing a target is what grants it an occurrence: an API that carries the secret in two places just names both (`substituteIn=["header:authorization", "body:signature"]`) instead of raising a count. A repeat at the same target still blocks. Setting `maxOccurrences` is now a schema error that points at the replacement.
31 changes: 20 additions & 11 deletions packages/varlock-website/src/content/docs/guides/proxy/rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ A `@proxy(...)` rule supports more than just a domain:
| `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](#substitution-surface)). |
| `maxOccurrences` | How many times the placeholder may appear in one request before it's blocked (default `1`) (see [Substitution surface](#substitution-surface)). |
| `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)). |

`domain` and `method` take either a single value or an **array literal** for lists:
Expand Down Expand Up @@ -46,7 +45,7 @@ When one host needs several path/method policies, write the `domain` once and li
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](#routing-rules) (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).
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](#routing-rules) (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

Expand All @@ -68,9 +67,9 @@ Even in `permissive` mode, if a request carries a placeholder that **no rule inj

### 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.
Matching a rule decides **which host** a secret may go to. `substituteIn` decides **where inside the request** the placeholder gets swapped for the real value, and each place you name is worth exactly one swap. This exists 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:
**`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 occurrence is **skipped**: the part is never rewritten, and the request is forwarded with the literal placeholder still in place, 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 skipped occurrence is recorded as a `skipped-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:

| Target | Allows substitution in |
|---|---|
Expand All @@ -82,30 +81,40 @@ Matching a rule decides **which host** a secret may go to. Two more guards decid
| `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.
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.

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.
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 skipped (left 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.

```env-spec title=".env.schema"
# 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.
**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 a count limit alone doesn't close it: the placeholder placed **once** in the wrong field would still pass. 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.
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, and scope the rule tightly with `path` and `method` to the one endpoint that needs it. 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:
**One substitution per target.** Each target you list is worth exactly one swap per request. A second occurrence at the *same* target 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, because the proxy has no way to tell which copy is the real use. Skipped occurrences belong to no target, so they never count.

Since the budget is per target, an API that carries the secret in two places just needs both places named, with nothing else to configure:

```env-spec title=".env.schema"
# @proxy(domain="api.example.com", substituteIn=["header:authorization", "body:signature"], maxOccurrences=2)
# 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()
```

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.
Note that the bare `header` target is a *single* target covering every header, so under the default the secret may be substituted into one header, not one per header. For an API that wants it in two headers, name them: `substituteIn=["header:authorization", "header:x-api-key"]`. Naming the second place is always the fix, and it tightens the rule rather than loosening it.

**When the proxy still blocks.** Skipping only applies to surfaces the rule has no targets on. Two cases fail closed:

- **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.
- **A repeat at one target**, as above: two copies at the same target are ambiguous about which is the real use, so the request is blocked rather than substituting both.

Blocked requests get a `403` naming the item, where the placeholder was found, and how to adjust the rule if the placement is legitimate.

## Controlling what the agent sees

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ These flags apply only when **starting** a proxy. They also work on `proxy run`

## Auditing

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).
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 occurrence is [skipped](/guides/proxy/rules/#substitution-surface) (it appeared in a surface its rule doesn't substitute in, and was forwarded unsubstituted), the log also gets a `skipped-placeholder` line naming the item key and where the placeholder sat.

```bash
varlock proxy audit # current/most-recent session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ OPENAI_API_KEY=yourPreferredPlugin()

Routes an item's secret through the [credential proxy](/guides/proxy/) so an untrusted child process only ever sees a placeholder, while the real value is injected into matching outbound requests at the network boundary. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive).

**Function form** `@proxy(domain=..., [path], [method], [block], [approval], [keys], [substituteIn], [maxOccurrences], [rules])`:
**Function form** `@proxy(domain=..., [path], [method], [block], [approval], [keys], [substituteIn], [rules])`:

| Option | Meaning |
|---|---|
Expand All @@ -342,9 +342,8 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt
| `block` | `block=true` denies matching requests outright. |
| `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. |
| `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. |
| `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). |
| `maxOccurrences` | Max times the placeholder may appear in one request before it's blocked (default `1`). See [Substitution surface](/guides/proxy/rules/#substitution-surface). |
| `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). |
| `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). Each target listed is worth one substitution per request; a repeat at the same target blocks. A placeholder in a surface with no targets is skipped, forwarded 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). |
| `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` (not `domain`/`keys`) and injects nothing on its own. See the [Grouping rules guide](/guides/proxy/rules/#grouping-rules-for-one-domain). |

The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`).

Expand Down
18 changes: 15 additions & 3 deletions packages/varlock/src/cli/commands/proxy.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createProxyAuditLog,
readProxyAuditLines,
type ProxyActivity,
type ProxyAuditSkippedPlaceholder,
type ProxyAuditEntry,
type ProxyAuditLog,
} from '../../proxy/audit';
Expand Down Expand Up @@ -633,7 +634,12 @@ function formatProxyRequestLog(a: ProxyActivity): string {
const inject = a.injectedKeys?.length
? ` ${ansis.dim('inject:')} ${ansis.yellow(a.injectedKeys.join(', '))}`
: '';
return `${arrow} ${formatProxyTarget(a.method, a.host, a.path)}${decision}${inject}`;
// A placeholder left inert in an untargeted surface (usually benign, e.g. an
// agent quoting its own placeholder), surfaced so probing stays visible.
const skipped = a.skippedPlaceholders?.length
? ` ${ansis.dim('skipped:')} ${ansis.yellow(a.skippedPlaceholders.map((c) => `${c.key} (${c.locations.join(', ')})`).join(', '))}`
: '';
return `${arrow} ${formatProxyTarget(a.method, a.host, a.path)}${decision}${inject}${skipped}`;
}

/** A one-line live log of a forwarded response: `← POST host/path 200 scrubbed: KEY`. */
Expand Down Expand Up @@ -2260,7 +2266,11 @@ export async function pruneAction(ctx: any) {
console.log(`Pruned ${removed.length} ended proxy session${removed.length === 1 ? '' : 's'}.`);
}

function formatAuditEntry(entry: ProxyAuditEntry): string {
function formatAuditEntry(entry: ProxyAuditEntry | ProxyAuditSkippedPlaceholder): string {
if (entry.type === 'skipped-placeholder') {
const rule = entry.ruleId ? ` rule="${entry.ruleId}"` : '';
return `${entry.ts} ${'skipped'.padEnd(16)} ${entry.method.padEnd(7)} ${entry.host}${entry.path} key=${entry.key} in=${entry.locations.join(',')}${rule}`;
}
const injected = entry.injected && entry.injectedKeys?.length
? ` injected=${entry.injectedKeys.join(',')}`
: '';
Expand Down Expand Up @@ -2297,7 +2307,9 @@ export async function auditAction(ctx: any) {
return;
}

const entries = lines.filter((line): line is ProxyAuditEntry => line.type === 'request');
const entries = lines.filter(
(line): line is ProxyAuditEntry | ProxyAuditSkippedPlaceholder => line.type === 'request' || line.type === 'skipped-placeholder',
);
if (!entries.length) {
console.log('No audit entries for this session.');
return;
Expand Down
Loading
Loading