Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d945ba3
feat(varlock): proxy request signing via transform= on @proxy rules
theoephraim Aug 14, 2026
c8750f1
refactor(varlock): per-scheme option specs for proxy transform valida…
theoephraim Aug 14, 2026
d035397
feat(varlock): aws-sigv4 proxy transform scheme
theoephraim Aug 14, 2026
a617e8c
refactor(varlock): plugin-registered transform schemes; move aws-sigv…
theoephraim Aug 26, 2026
d6a7b3f
docs: sigv4 scheme covers S3-compatible services; categorize proxy pl…
theoephraim Aug 28, 2026
968258b
feat(varlock): built-in http-basic transform scheme
theoephraim Aug 28, 2026
1b68f60
feat(varlock): http-basic username/password surface + item-name guard…
theoephraim Aug 28, 2026
1ff66ba
feat(varlock): transform item options use $ITEM references, captured …
theoephraim Aug 28, 2026
bea9ff6
feat(varlock): http-basic literal password with secretIn=username
theoephraim Aug 28, 2026
0e2d0c8
docs: clarify username ref vs secretIn=username for http-basic
theoephraim Aug 28, 2026
a8b4d33
docs: sync reference pages to the current transform surface; pin exam…
theoephraim Aug 28, 2026
e4b27a1
refactor(varlock): drop secretIn; http-basic username/password are sy…
theoephraim Aug 30, 2026
e4625ac
test: cover getTransformRoleKeys across scheme shapes
theoephraim Aug 30, 2026
a6193c2
fix(varlock): address open PR review findings on the transform surface
theoephraim Aug 30, 2026
1cdbfb1
fix(varlock): block interpolating a sensitive value into a transform …
theoephraim Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/proxy-request-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: minor
---

Credential proxy: request signing via transform= on @proxy rules. Generic HMAC (hmac-sha256/hmac-sha512) signs the final outbound request with a signing secret the agent never holds; aws-sigv4 re-signs AWS SDK requests made with placeholder credentials, deriving region/service from the request with optional allowlists.
208 changes: 204 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,9 @@
"workflow": "release.yaml",
"environment": "publish",
"permissions": "publish"
},
"dependencies": {
"@smithy/protocol-http": "^5.6.0",
"@smithy/signature-v4": "^5.7.0"
}
}
74 changes: 74 additions & 0 deletions packages/varlock-website/src/content/docs/guides/proxy/rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,80 @@ 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.

### Request signing

Some APIs never receive the secret at all. Instead, every request carries an HMAC signature computed *with* it: crypto exchange APIs (Coinbase, Kraken), and many webhook and partner APIs that require an `HMAC-SHA256` of the request body in a signature header. Substitution can't cover these, since the secret never appears in the request, so there is no placeholder to swap.

The `transform=` option on a `@proxy` rule makes the proxy compute the signature at the wire instead. This is a stronger boundary than substitution: a substituted credential is a string the child *could* have held, but here the child cannot produce a valid signature even in principle, because it never holds the signing key.

```env-spec title=".env.schema"
# @proxy(domain="api.exchange.com", transform={
# scheme="hmac-sha256",
# stringToSign="{timestamp}{method}{pathWithQuery}{body}",
# signatureHeader="X-ACCESS-SIGN",
# timestampHeader="X-ACCESS-TIMESTAMP",
# keyId="EXCHANGE_API_KEY", keyHeader="X-ACCESS-KEY",
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
# encoding="hex",
# })
EXCHANGE_API_SECRET=yourPreferredPlugin()

# @sensitive
EXCHANGE_API_KEY=yourPreferredPlugin()
```

The signature is computed over the **final outbound request**, after placeholder substitution, so it covers exactly the bytes the upstream receives. Any signature or timestamp headers the child sent are overwritten (an SDK configured with placeholder credentials produces a garbage signature; the proxy replaces it with a valid one).

Each scheme accepts its own option set. Options for the `hmac-*` schemes:

| Option | Meaning |
|---|---|
| `scheme` | **(required)** `hmac-sha256`, `hmac-sha512`, or `aws-sigv4` (see below). |
| `stringToSign` | **(required)** Template for the signed message. Fields: `{timestamp}` `{method}` `{path}` `{pathWithQuery}` `{query}` `{host}` `{body}`. |
| `signatureHeader` | **(required)** Header the signature is written to. |
| `secretKey` | Item whose value is the HMAC key. Defaults to the decorated item on an attached rule; **required** on a detached rule. |
| `keyId` / `keyHeader` | Optional companion item (an API key id) and the header it is written to. Set together. |
| `timestampHeader` | Header the signing timestamp is written to. |
| `encoding` | Signature output encoding: `base64` _(default)_ or `hex`. |
| `keyEncoding` | How the secret decodes into key bytes: `raw` _(default)_, `base64`, or `hex`. Some APIs (Coinbase Prime) issue base64-encoded secrets. |
| `timestampFormat` | `unix-seconds` _(default)_, `unix-millis`, `unix-nanos`, or `rfc3339`. |

A few properties worth knowing:

- **The signing secret never travels.** It is consumed by the signer, not substituted, so `substituteIn` and `maxOccurrences` don't apply to it. If its placeholder shows up anywhere in any request, the request is blocked: there is no legitimate reason for the child to send it. The `keyId` item is wire-visible (it is an API key id, not a secret key), so it substitutes normally like a `keys=` entry.
- **The transform rides its rule's match.** A request is signed when a rule carrying `transform=` matches it. For a domain with several rules, put the transform on one broad `domain=`-only rule and keep `path`/`method`/`block`/`approval` refinements on separate rules; two *different* transform configs matching the same request is a schema misconfiguration and the request fails closed.
- **TLS only.** Like injection, signing refuses cleartext connections: a signature over plain http is trivially replayable.
- If the transform-carrying rule requires `approval`, the request is signed only after the approval gate passes, mirroring how approval-gated injection works.

#### AWS SigV4

The `aws-sigv4` scheme re-signs AWS SDK requests. AWS never receives the secret access key either; every request carries a signature computed from it. Configure the SDK in the child with the **placeholder** credentials (varlock's proxied env does this automatically) and point it at the proxy; the SDK signs normally, and the proxy strips the placeholder signature and re-signs with the real keys.

```env-spec title=".env.schema"
# @proxy(domain="*.amazonaws.com", transform={
# scheme="aws-sigv4", keyId="AWS_ACCESS_KEY_ID",
# allowedServices=[bedrock, s3],
# })
AWS_SECRET_ACCESS_KEY=somePlugin()

# @sensitive
AWS_ACCESS_KEY_ID=somePlugin()
```

The region and service need no configuration: they are parsed from the inbound request's credential scope (`Credential=KEY/DATE/REGION/SERVICE/aws4_request`), so one rule covers every AWS service and region the client talks to. The optional allowlists gate what the proxy is willing to sign for.

| Option | Meaning |
|---|---|
| `keyId` | **(required)** Item holding the AWS access key id (travels in the Credential scope). |
| `sessionToken` | Item holding a session token for temporary credentials, sent and signed as `X-Amz-Security-Token`. |
| `allowedRegions` | Only sign requests whose scope names one of these regions, e.g. `[us-east-1]`. Omitted = any. |
| `allowedServices` | Only sign requests whose scope names one of these services, e.g. `[bedrock, s3]`. Omitted = any. |

Details worth knowing:

- A request without an inbound SigV4 signature is blocked with a message explaining the placeholder-signing setup (there is nothing to derive the region/service from). Pre-signed URLs (`X-Amz-Credential` in the query string) are not supported yet and are blocked with a distinct message.
- The payload hash covers the exact outbound body bytes. If the client signed with the `UNSIGNED-PAYLOAD` sentinel (S3 streaming uploads), the proxy preserves it.
- Signing uses the official AWS SDK v3 signer, including the S3-specific path-encoding rules.

## Controlling what the agent sees

By default, varlock applies **least privilege** to the proxied child:
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], [maxOccurrences], [rules], [transform])`:

| Option | Meaning |
|---|---|
Expand All @@ -345,6 +345,7 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt
| `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). |
| `transform` | Request signing config, e.g. `transform={scheme="hmac-sha256", stringToSign="{timestamp}{method}{path}{body}", signatureHeader="X-Signature"}` or `transform={scheme="aws-sigv4", keyId="AWS_ACCESS_KEY_ID"}`. The proxy signs the final outbound request with the item's real value (which is consumed by the signer and never sent): an HMAC written into the named header, or a full AWS SigV4 re-sign of an SDK request. `secretKey` defaults to the decorated item on an attached rule and is required on a detached one. See [Request signing](/guides/proxy/rules/#request-signing) for per-scheme options. |

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow.
### `@proxy()`
**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, approval?, keys?: string[], rules?: object[], ...)`

Defines a **detached** [credential proxy](/guides/proxy/) rule: a domain-level policy (match or `block`). It injects no secret on its own, but can inject named items via `keys=[...]`.
Defines a **detached** [credential proxy](/guides/proxy/) rule: a domain-level policy (match or `block`). It injects no secret on its own, but can inject named items via `keys=[...]`, or sign matching requests via `transform={..., secretKey="ITEM_NAME"}` (`secretKey` is required on a detached rule since there is no attached item to default to; see [Request signing](/guides/proxy/rules/#request-signing)).

This is the header (root) form of the decorator. To inject a specific secret into requests for a domain, put [`@proxy`](/reference/item-decorators/#proxy) on the **item** instead (an _attached_ rule). The two forms share the same options; see the [item decorator reference](/reference/item-decorators/#proxy).

Expand Down
31 changes: 29 additions & 2 deletions packages/varlock/src/env-graph/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ResolutionError, SchemaError, type VarlockError } from './errors';
import type { EnvGraph } from './env-graph';
import { parseKeyFilterArgs, applyKeyFilter, type KeyFilter } from './key-filter';
import { parseDuration } from '../../lib/duration';
import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget } from '../../proxy/types';
import { PROXY_APPROVAL_EACH_VALUES, parseProxySubstitutionTarget, validateProxyTransformConfig } from '../../proxy/types';


export abstract class DecoratorInstance {
Expand Down Expand Up @@ -379,7 +379,7 @@ function assertProxyStringListArg(
* literal and `keys` as an array literal; rejects positional args; validates the
* approval options.
*/
const VALID_PROXY_OPTIONS = ['domain', 'path', 'method', 'keys', 'block', 'approval', 'substituteIn', 'maxOccurrences', 'rules'] as const;
const VALID_PROXY_OPTIONS = ['domain', 'path', 'method', 'keys', 'block', 'approval', 'substituteIn', 'maxOccurrences', 'rules', 'transform'] as const;
/** Per-entry options inside the `rules=[{...}]` array form. Each entry is a
* policy refinement for the parent's `domain`, so it cannot re-set `domain` or
* `keys` (injection is controlled by the parent rule). */
Expand Down Expand Up @@ -477,6 +477,32 @@ function assertProxyApprovalArg(resolver: Resolver | undefined): void {
}
}

/**
* `transform` is an options object describing a request-signing scheme
* (`transform={scheme="hmac-sha256", stringToSign=..., signatureHeader=...}`).
* Statically validates the literal entries via the shared config validator
* (partial mode - dynamic entries re-check at resolve time). Placement-specific
* requirements (`secretKey` on detached rules) are enforced when rules are built.
*/
function assertProxyTransformArg(resolver: Resolver | undefined): void {
if (!resolver) return;
if (!(resolver instanceof ObjectLiteralResolver)) {
if (resolver.isStatic) {
throw new SchemaError('@proxy: transform must be an options object, e.g. transform={scheme="hmac-sha256", stringToSign="{timestamp}{method}{path}{body}", signatureHeader="X-Signature"}');
}
return; // dynamic expression - validated at resolve time
}
const inner = resolver.objArgs ?? {};
const staticEntries: Record<string, unknown> = {};
for (const [key, val] of Object.entries(inner)) {
// Include every key so unknown options fail loudly here; only static values
// get their content checked.
staticEntries[key] = val?.isStatic ? val.staticValue : undefined;
}
const error = validateProxyTransformConfig(staticEntries, { partial: true });
if (error) throw new SchemaError(`@proxy: ${error}`);
}

/**
* The `rules=[{...}]` form: a list of policy refinements that share the parent's
* `domain`. Each entry may set path/method/block/approval (but not domain/keys —
Expand Down Expand Up @@ -534,6 +560,7 @@ function validateProxyFunctionArgs(argsVal: Resolver): void {
assertProxySubstituteInArg(argsVal.objArgs?.substituteIn);
assertProxyMaxOccurrencesArg(argsVal.objArgs?.maxOccurrences);
assertProxyRulesArg(argsVal.objArgs?.rules);
assertProxyTransformArg(argsVal.objArgs?.transform);

if (argsVal.arrArgs?.length) {
throw new SchemaError('@proxy: positional args are not supported - use keys=[ITEM_A, ITEM_B] to attach items');
Expand Down
Loading
Loading