Skip to content
5 changes: 5 additions & 0 deletions .bumpy/proxy-carry-inert-placeholders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
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 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.
17 changes: 11 additions & 6 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,7 @@ 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)). |
| `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)). |
| `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 @@ -70,7 +70,7 @@ Even in `permissive` mode, if a request carries a placeholder that **no rule inj

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.

**`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 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:

| Target | Allows substitution in |
|---|---|
Expand All @@ -82,9 +82,9 @@ 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 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.

```env-spec title=".env.schema"
# OAuth token exchange carries the secret in a form field:
Expand All @@ -98,14 +98,19 @@ A body path is a dotted path into a JSON body (`client_secret`, `data.token`, `i

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.

**`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:
**`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:

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

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 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.

```bash
varlock proxy audit # current/most-recent session
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +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). |
| `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). |
| `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). |
| `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). |

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 ProxyAuditCarriedPlaceholder,
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 carried = a.carriedPlaceholders?.length
? ` ${ansis.dim('carried:')} ${ansis.yellow(a.carriedPlaceholders.map((c) => `${c.key} (${c.locations.join(', ')})`).join(', '))}`
: '';
return `${arrow} ${formatProxyTarget(a.method, a.host, a.path)}${decision}${inject}${carried}`;
}

/** 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 | ProxyAuditCarriedPlaceholder): string {
if (entry.type === 'carried-placeholder') {
const rule = entry.ruleId ? ` rule="${entry.ruleId}"` : '';
return `${entry.ts} ${'carried'.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 | ProxyAuditCarriedPlaceholder => line.type === 'request' || line.type === 'carried-placeholder',
);
if (!entries.length) {
console.log('No audit entries for this session.');
return;
Expand Down
29 changes: 29 additions & 0 deletions packages/varlock/src/proxy/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ describe('proxy audit log', () => {
expect((lines[2] as ProxyAuditEntry).injectedKeys).toBeUndefined();
});

test('emits one carried-placeholder line per carried item, sharing the request fingerprint', async () => {
const uuid = 'carried-lines';
const log = createProxyAuditLog(uuid);
log.record(allowActivity({
carriedPlaceholders: [
{ key: 'API_KEY', locations: ['body'] },
{ key: 'OTHER_KEY', locations: ['header:x-debug', 'body'] },
],
}));
await log.flush();

const lines = await readProxyAuditLines(uuid);
expect(lines).toHaveLength(3);
const entry = lines[0] as ProxyAuditEntry;
expect(entry).toMatchObject({ type: 'request', decision: 'allow' });
expect(lines[1]).toMatchObject({
type: 'carried-placeholder',
key: 'API_KEY',
locations: ['body'],
requestHash: entry.requestHash,
ruleId: entry.ruleId,
});
expect(lines[2]).toMatchObject({
type: 'carried-placeholder',
key: 'OTHER_KEY',
locations: ['header:x-debug', 'body'],
});
});

test('never persists a secret value, even when injectedKeys are present', async () => {
const uuid = 'no-secrets';
const log = createProxyAuditLog(uuid);
Expand Down
49 changes: 47 additions & 2 deletions packages/varlock/src/proxy/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export type ProxyActivity = {
ruleId?: string;
/** Keys (names, never values) of the managed items actually injected into this request. */
injectedKeys?: Array<string>;
/**
* Injected items whose placeholder also appeared in a surface their rule doesn't
* substitute in, forwarded unsubstituted (inert). Each produces a
* `carried-placeholder` audit line alongside the request entry.
*/
carriedPlaceholders?: Array<{ key: string; locations: Array<string> }>;
};

/** First line of every audit file — makes the file self-describing after the session record is gone. */
Expand Down Expand Up @@ -68,7 +74,29 @@ export type ProxyAuditEntry = {
ruleId?: string;
};

export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry;
/**
* One carried-placeholder event: an injected item's placeholder appeared in a
* request surface its rule has no substitution targets on, and was forwarded
* unsubstituted (an unswapped placeholder is inert). Usually benign (an agent
* quoting its own placeholder), but logged per item so probing stays visible.
*/
export type ProxyAuditCarriedPlaceholder = {
type: 'carried-placeholder';
ts: string;
host: string;
method: string;
/** Path only, no query, placeholder form. */
path: string;
/** Matches the accompanying request entry's fingerprint. */
requestHash: string;
/** Key (name, never value) of the managed item whose placeholder was carried. */
key: string;
/** Where the unsubstituted occurrences sat, e.g. `body`, `path`, `query`, `header:<name>`. */
locations: Array<string>;
ruleId?: string;
};

export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry | ProxyAuditCarriedPlaceholder;

// Resolved lazily (not a module-load const) so it honors the active
// XDG_CONFIG_HOME / legacy-dir resolution at call time. Co-located in the
Expand Down Expand Up @@ -128,7 +156,24 @@ export function createProxyAuditLog(uuid: string, header?: Omit<ProxyAuditHeader
filePath,
/** Record a request's decision. Returns immediately; the write is queued. */
record(activity: ProxyActivity) {
enqueue(activityToEntry(activity, new Date().toISOString()));
const ts = new Date().toISOString();
const entry = activityToEntry(activity, ts);
enqueue(entry);
// One carried-placeholder line per carried item, sharing the request's
// fingerprint so the two can be correlated.
for (const carried of activity.carriedPlaceholders ?? []) {
enqueue({
type: 'carried-placeholder',
ts,
host: activity.host,
method: activity.method,
path: activity.path,
requestHash: entry.requestHash,
key: carried.key,
locations: carried.locations,
...(activity.ruleId ? { ruleId: activity.ruleId } : {}),
});
}
},
/** Resolve once all queued writes have flushed to disk. */
async flush() {
Expand Down
Loading
Loading