Skip to content

Commit 9602557

Browse files
committed
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
1 parent 188b726 commit 9602557

11 files changed

Lines changed: 468 additions & 172 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
varlock: minor
3+
---
4+
5+
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.

packages/varlock-website/src/content/docs/guides/proxy/rules.mdx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A `@proxy(...)` rule supports more than just a domain:
1515
| `block` | `block=true` denies matching requests outright (fail closed). |
1616
| `keys` | Array of additional item names to inject for this rule, e.g. `keys=[STRIPE_KEY, WEBHOOK_SECRET]`. |
1717
| `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)). |
1919
| `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)). |
2020

2121
`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
7070

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

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:
7474

7575
| Target | Allows substitution in |
7676
|---|---|
@@ -82,9 +82,9 @@ Matching a rule decides **which host** a secret may go to. Two more guards decid
8282
| `body:client_secret` | only the value at that body path (see below) |
8383
| `body:*` | anywhere in the body (escape hatch for unparseable bodies, see below) |
8484

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

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

8989
```env-spec title=".env.schema"
9090
# 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
9898

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

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:
102102

103103
```env-spec title=".env.schema"
104104
# @proxy(domain="api.example.com", substituteIn=["header:authorization", "body:signature"], maxOccurrences=2)
105105
SIGNING_KEY=yourPreferredPlugin()
106106
```
107107

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

110115
## Controlling what the agent sees
111116

packages/varlock-website/src/content/docs/guides/proxy/running.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ These flags apply only when **starting** a proxy. They also work on `proxy run`
116116

117117
## Auditing
118118

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

121121
```bash
122122
varlock proxy audit # current/most-recent session

packages/varlock-website/src/content/docs/reference/item-decorators.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,8 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt
342342
| `block` | `block=true` denies matching requests outright. |
343343
| `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. |
344344
| `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). |
347347
| `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). |
348348

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

packages/varlock/src/cli/commands/proxy.command.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
createProxyAuditLog,
1818
readProxyAuditLines,
1919
type ProxyActivity,
20+
type ProxyAuditCarriedPlaceholder,
2021
type ProxyAuditEntry,
2122
type ProxyAuditLog,
2223
} from '../../proxy/audit';
@@ -633,7 +634,12 @@ function formatProxyRequestLog(a: ProxyActivity): string {
633634
const inject = a.injectedKeys?.length
634635
? ` ${ansis.dim('inject:')} ${ansis.yellow(a.injectedKeys.join(', '))}`
635636
: '';
636-
return `${arrow} ${formatProxyTarget(a.method, a.host, a.path)}${decision}${inject}`;
637+
// A placeholder left inert in an untargeted surface (usually benign, e.g. an
638+
// agent quoting its own placeholder), surfaced so probing stays visible.
639+
const carried = a.carriedPlaceholders?.length
640+
? ` ${ansis.dim('carried:')} ${ansis.yellow(a.carriedPlaceholders.map((c) => `${c.key} (${c.locations.join(', ')})`).join(', '))}`
641+
: '';
642+
return `${arrow} ${formatProxyTarget(a.method, a.host, a.path)}${decision}${inject}${carried}`;
637643
}
638644

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

2263-
function formatAuditEntry(entry: ProxyAuditEntry): string {
2269+
function formatAuditEntry(entry: ProxyAuditEntry | ProxyAuditCarriedPlaceholder): string {
2270+
if (entry.type === 'carried-placeholder') {
2271+
const rule = entry.ruleId ? ` rule="${entry.ruleId}"` : '';
2272+
return `${entry.ts} ${'carried'.padEnd(16)} ${entry.method.padEnd(7)} ${entry.host}${entry.path} key=${entry.key} in=${entry.locations.join(',')}${rule}`;
2273+
}
22642274
const injected = entry.injected && entry.injectedKeys?.length
22652275
? ` injected=${entry.injectedKeys.join(',')}`
22662276
: '';
@@ -2297,7 +2307,9 @@ export async function auditAction(ctx: any) {
22972307
return;
22982308
}
22992309

2300-
const entries = lines.filter((line): line is ProxyAuditEntry => line.type === 'request');
2310+
const entries = lines.filter(
2311+
(line): line is ProxyAuditEntry | ProxyAuditCarriedPlaceholder => line.type === 'request' || line.type === 'carried-placeholder',
2312+
);
23012313
if (!entries.length) {
23022314
console.log('No audit entries for this session.');
23032315
return;

packages/varlock/src/proxy/audit.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,35 @@ describe('proxy audit log', () => {
8080
expect((lines[2] as ProxyAuditEntry).injectedKeys).toBeUndefined();
8181
});
8282

83+
test('emits one carried-placeholder line per carried item, sharing the request fingerprint', async () => {
84+
const uuid = 'carried-lines';
85+
const log = createProxyAuditLog(uuid);
86+
log.record(allowActivity({
87+
carriedPlaceholders: [
88+
{ key: 'API_KEY', locations: ['body'] },
89+
{ key: 'OTHER_KEY', locations: ['header:x-debug', 'body'] },
90+
],
91+
}));
92+
await log.flush();
93+
94+
const lines = await readProxyAuditLines(uuid);
95+
expect(lines).toHaveLength(3);
96+
const entry = lines[0] as ProxyAuditEntry;
97+
expect(entry).toMatchObject({ type: 'request', decision: 'allow' });
98+
expect(lines[1]).toMatchObject({
99+
type: 'carried-placeholder',
100+
key: 'API_KEY',
101+
locations: ['body'],
102+
requestHash: entry.requestHash,
103+
ruleId: entry.ruleId,
104+
});
105+
expect(lines[2]).toMatchObject({
106+
type: 'carried-placeholder',
107+
key: 'OTHER_KEY',
108+
locations: ['header:x-debug', 'body'],
109+
});
110+
});
111+
83112
test('never persists a secret value, even when injectedKeys are present', async () => {
84113
const uuid = 'no-secrets';
85114
const log = createProxyAuditLog(uuid);

packages/varlock/src/proxy/audit.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export type ProxyActivity = {
3838
ruleId?: string;
3939
/** Keys (names, never values) of the managed items actually injected into this request. */
4040
injectedKeys?: Array<string>;
41+
/**
42+
* Injected items whose placeholder also appeared in a surface their rule doesn't
43+
* substitute in, forwarded unsubstituted (inert). Each produces a
44+
* `carried-placeholder` audit line alongside the request entry.
45+
*/
46+
carriedPlaceholders?: Array<{ key: string; locations: Array<string> }>;
4147
};
4248

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

71-
export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry;
77+
/**
78+
* One carried-placeholder event: an injected item's placeholder appeared in a
79+
* request surface its rule has no substitution targets on, and was forwarded
80+
* unsubstituted (an unswapped placeholder is inert). Usually benign (an agent
81+
* quoting its own placeholder), but logged per item so probing stays visible.
82+
*/
83+
export type ProxyAuditCarriedPlaceholder = {
84+
type: 'carried-placeholder';
85+
ts: string;
86+
host: string;
87+
method: string;
88+
/** Path only, no query, placeholder form. */
89+
path: string;
90+
/** Matches the accompanying request entry's fingerprint. */
91+
requestHash: string;
92+
/** Key (name, never value) of the managed item whose placeholder was carried. */
93+
key: string;
94+
/** Where the unsubstituted occurrences sat, e.g. `body`, `path`, `query`, `header:<name>`. */
95+
locations: Array<string>;
96+
ruleId?: string;
97+
};
98+
99+
export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry | ProxyAuditCarriedPlaceholder;
72100

73101
// Resolved lazily (not a module-load const) so it honors the active
74102
// XDG_CONFIG_HOME / legacy-dir resolution at call time. Co-located in the
@@ -128,7 +156,24 @@ export function createProxyAuditLog(uuid: string, header?: Omit<ProxyAuditHeader
128156
filePath,
129157
/** Record a request's decision. Returns immediately; the write is queued. */
130158
record(activity: ProxyActivity) {
131-
enqueue(activityToEntry(activity, new Date().toISOString()));
159+
const ts = new Date().toISOString();
160+
const entry = activityToEntry(activity, ts);
161+
enqueue(entry);
162+
// One carried-placeholder line per carried item, sharing the request's
163+
// fingerprint so the two can be correlated.
164+
for (const carried of activity.carriedPlaceholders ?? []) {
165+
enqueue({
166+
type: 'carried-placeholder',
167+
ts,
168+
host: activity.host,
169+
method: activity.method,
170+
path: activity.path,
171+
requestHash: entry.requestHash,
172+
key: carried.key,
173+
locations: carried.locations,
174+
...(activity.ruleId ? { ruleId: activity.ruleId } : {}),
175+
});
176+
}
132177
},
133178
/** Resolve once all queued writes have flushed to disk. */
134179
async flush() {

0 commit comments

Comments
 (0)