From ef60322d0261012d16e7d94b3528bed2ca89290a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 6 Jun 2026 00:12:41 -0700 Subject: [PATCH 01/64] Add phase-1 proxy guardrails and strict egress enforcement --- docs/proxy-phase1-notes.md | 60 +++ packages/varlock/src/cli/cli-executable.ts | 3 + .../varlock/src/cli/commands/run.command.ts | 62 ++- .../src/cli/helpers/proxy-context-guard.ts | 172 +++++++ .../cli/helpers/proxy-schema-fingerprint.ts | 27 + .../helpers/test/proxy-context-guard.test.ts | 130 +++++ .../varlock/src/env-graph/lib/config-item.ts | 18 +- .../varlock/src/env-graph/lib/data-types.ts | 6 + .../varlock/src/env-graph/lib/decorators.ts | 67 ++- .../varlock/src/env-graph/lib/env-graph.ts | 111 ++++- .../src/env-graph/test/proxy-mode.test.ts | 81 +++ packages/varlock/src/proxy/placeholder.ts | 104 ++++ .../varlock/src/proxy/runtime-proxy.test.ts | 65 +++ packages/varlock/src/proxy/runtime-proxy.ts | 463 ++++++++++++++++++ packages/varlock/src/proxy/types.ts | 20 + 15 files changed, 1382 insertions(+), 7 deletions(-) create mode 100644 docs/proxy-phase1-notes.md create mode 100644 packages/varlock/src/cli/helpers/proxy-context-guard.ts create mode 100644 packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts create mode 100644 packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts create mode 100644 packages/varlock/src/env-graph/test/proxy-mode.test.ts create mode 100644 packages/varlock/src/proxy/placeholder.ts create mode 100644 packages/varlock/src/proxy/runtime-proxy.test.ts create mode 100644 packages/varlock/src/proxy/runtime-proxy.ts create mode 100644 packages/varlock/src/proxy/types.ts diff --git a/docs/proxy-phase1-notes.md b/docs/proxy-phase1-notes.md new file mode 100644 index 000000000..d4024e4e4 --- /dev/null +++ b/docs/proxy-phase1-notes.md @@ -0,0 +1,60 @@ +# Proxy Mode Phase 1 Notes + +## What We Learned + +- `--proxy` placeholder injection is useful, but not sufficient on its own. +- In proxied child processes, nested `varlock` invocations can potentially recover raw secrets unless explicitly guarded. +- For the current risk model (preventing accidental/bad agent behavior, not defeating a determined local attacker), explicit command guardrails are a practical first step. +- For coding agents, reading and editing `.env.schema` is useful and should stay possible. + +## Phase 1 Goal + +Make proxied runs safer by default by blocking obvious raw-secret recovery paths while preserving useful debugging workflows. + +## Phase 1 Safety Model + +- Treat proxied child process as untrusted for secret retrieval. +- Allow safe inspection commands. +- Deny commands/formats that can reveal plaintext secrets. +- Log and clearly explain blocked actions. + +## Immediate Guardrails + +- Add a proxied-child marker env var in `varlock run --proxy`. +- In proxied context, deny nested: + - `varlock run` + - `varlock printenv` + - `varlock reveal` +- In proxied context, restrict `varlock load` output: + - Allow default `pretty` output. + - Allow `json` / `json-full` only with `--agent`. + - Deny `env` / `shell` formats. +- In proxied context, verify schema fingerprint for `varlock load`: + - Fingerprint is captured at proxy start from approved schema shape. + - If schema changes during run, proxied `load` is blocked until proxy restart/approval. + +## Known Limitation (Important) + +If the agent edits `.env.schema` to downgrade an item from sensitive to non-sensitive, a schema fingerprint mismatch now blocks proxied `load`; full approval workflows are still needed so changes can be intentionally reviewed and activated. + +## Planned Follow-up + +Introduce an explicit approval gate for schema changes before they affect active proxy behavior: + +1. Agent can edit schema -> state becomes `pending`. +2. Proxy keeps using last approved policy. +3. Native macOS binary presents review/approval UI. +4. Only approved changes are activated (reload policy snapshot). + +This avoids silent sensitivity downgrades taking effect inside proxied runs. + +## macOS-First Approach + +- Trusted approver: Swift/native binary (menu bar + modal). +- Untrusted actor: proxied child process. +- CLI blocks and waits for binary approval for sensitive operations in later phase extensions. + +## Long-Term Direction + +- Phase 2: local isolated broker mode (secret values out of agent process memory). +- Phase 3: hosted/BYOC brokered control plane (stronger isolation + policy + audit), suitable for paid offering. diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index a864e2c9f..916ebf89c 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -12,6 +12,7 @@ import { InvalidEnvError } from './helpers/error-checks'; import { checkBunVersion } from '../lib/check-bun-version'; import { checkLocalVersionMismatch } from '../lib/check-local-version'; import packageJson from '../../package.json'; +import { enforceProxyContextGuards } from './helpers/proxy-context-guard'; // we'll import just the spec from each, so the implementations can be lazy loaded import { commandSpec as initCommandSpec } from './commands/init.command'; @@ -108,6 +109,8 @@ subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => aw await trackCommand('version'); } + await enforceProxyContextGuards(args); + // warn if standalone binary version differs from local node_modules install // skip for --version/--help/complete since those are quick informational commands if (__VARLOCK_SEA_BUILD__ && args[0] !== '--version' && args[0] !== '--help' && !isCompletionInvoke) { diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 7fe9d6ef5..aa0bce6b5 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -7,6 +7,8 @@ import { loadVarlockEnvGraph } from '../../lib/load-graph'; import { checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors } from '../helpers/error-checks'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; +import { buildProxySchemaFingerprint } from '../helpers/proxy-schema-fingerprint'; +import { startLocalProxyRuntime } from '../../proxy/runtime-proxy'; export const commandSpec = define({ name: 'run', @@ -36,6 +38,10 @@ export const commandSpec = define({ type: 'boolean', description: 'Pass @internal items through to the child process (by default they are stripped, even when set in the ambient env). Use this for a nested `varlock run` whose own resolution needs the internal value (e.g. a secret-zero token).', }, + proxy: { + type: 'boolean', + description: 'Enable proxy placeholder mode for items managed by @proxy rules', + }, path: { type: 'string', short: 'p', @@ -61,6 +67,7 @@ Examples: varlock run -- sh -c 'echo $MY_VAR' # Use shell expansion for env vars varlock run --inject vars -- sh # Inject only individual vars, no blob varlock run --inject blob -- node app.js # Inject only the blob, no individual vars + varlock run --proxy -- node app.js # Inject placeholders for @proxy-managed items varlock run --path .env.prod -- node app.js # Use a specific .env file varlock run --path ./config/ -- node app.js # Use a specific directory varlock run -p ./envs -p ./overrides -- node app.js # Use multiple directories @@ -162,6 +169,8 @@ function signalChild(signal: NodeJS.Signals | number) { } export const commandFn: TypedGunshiCommandFn = async (ctx) => { + let proxyRuntime: Awaited> | undefined; + // if "--" is present, split the args into our command and the rest, which will be another external command const argv = process.argv.slice(2); let restCommandArgs: Array = []; @@ -206,6 +215,21 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const serializedGraph = envGraph.getSerializedGraph(); const { resetRedactionMap } = await import('../../runtime/env'); const { createRedactedStreamWriter } = await import('../../runtime/lib/redact-stream'); + + const proxySchemaFingerprint = ctx.values.proxy ? buildProxySchemaFingerprint(envGraph) : undefined; + let proxyManagedItems = [] as Array>[number]>; + let proxyRules = [] as Array>[number]>; + + if (ctx.values.proxy) { + proxyManagedItems = await envGraph.getProxyManagedItems(); + proxyRules = await envGraph.getProxyRules(); + for (const managedItem of proxyManagedItems) { + resolvedEnv[managedItem.key] = managedItem.placeholder; + if (serializedGraph.config[managedItem.key]) { + serializedGraph.config[managedItem.key].value = managedItem.placeholder; + } + } + } // console.log(resolvedEnv); // handle deprecated --no-inject-graph flag @@ -221,11 +245,26 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } const injectVars = injectMode === 'all' || injectMode === 'vars'; const injectBlob = injectMode === 'all' || injectMode === 'blob'; + if (ctx.values.proxy) { + proxyRuntime = await startLocalProxyRuntime({ + managedItems: proxyManagedItems, + rules: proxyRules, + egressMode: serializedGraph.settings?.proxyEgress ?? 'permissive', + }); + } const fullInjectedEnv: NodeJS.ProcessEnv = { ...process.env, + ...(proxyRuntime?.env ?? {}), ...(injectVars ? resolvedEnv : {}), __VARLOCK_RUN: '1', // flag for a child process to detect it is running via `varlock run` + ...(ctx.values.proxy + ? { + __VARLOCK_PROXY_CHILD: '1', + __VARLOCK_PROXY_PARENT_PID: String(process.pid), + ...(proxySchemaFingerprint ? { __VARLOCK_PROXY_SCHEMA_FINGERPRINT: proxySchemaFingerprint } : {}), + } + : {}), ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(serializedGraph) } : {}), }; @@ -332,7 +371,25 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = detached: useProcessGroup, }); } else { - resetRedactionMap(serializedGraph); + // augment the redaction map with the real (injected-by-proxy) secret values so that + // any real value leaking back through the child's output is still redacted, even + // though the child only ever sees placeholders + const redactionGraph = { + ...serializedGraph, + config: { ...serializedGraph.config }, + }; + if (ctx.values.proxy) { + for (const managedItem of proxyManagedItems) { + const schemaItem = serializedGraph.config[managedItem.key]; + if (!schemaItem?.isSensitive) continue; + if (!managedItem.realValue || managedItem.realValue === schemaItem.value) continue; + redactionGraph.config[`__PROXY_REAL__${managedItem.key}`] = { + value: managedItem.realValue, + isSensitive: true, + }; + } + } + resetRedactionMap(redactionGraph); commandProcess = exec(rawCommand, commandArgsOnly, { stdin: 'inherit', @@ -368,6 +425,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = if (err.signal === 'SIGINT' && childCommandKilledFromRestart) { // console.log('child command failed due to being killed form restart'); childCommandKilledFromRestart = false; + await proxyRuntime?.stop(); return; } @@ -402,8 +460,10 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } if (!isWatchEnabled) { + await proxyRuntime?.stop(); return gracefulExit(exitCode); } else { + await proxyRuntime?.stop(); console.log('... watching for changes ...'); } }; diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts new file mode 100644 index 000000000..8cf5bff4f --- /dev/null +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -0,0 +1,172 @@ +import { CliExitError } from './exit-error'; +import { loadVarlockEnvGraph } from '../../lib/load-graph'; +import { buildProxySchemaFingerprint } from './proxy-schema-fingerprint'; + +export const PROXY_CHILD_ENV_VAR = '__VARLOCK_PROXY_CHILD'; +export const PROXY_SCHEMA_FINGERPRINT_ENV_VAR = '__VARLOCK_PROXY_SCHEMA_FINGERPRINT'; + +const COMMANDS_DENIED_IN_PROXY = new Set(['run', 'printenv', 'reveal']); + +export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boolean { + return env[PROXY_CHILD_ENV_VAR] === '1'; +} + +/** + * Parse a subset of load flags needed for proxied-context policy checks. + * We only care about format + agent safety flags. + */ +export function parseLoadSafetyArgs(argsAfterCommand: Array): { + format: 'pretty' | 'json' | 'env' | 'shell' | 'json-full'; + agent: boolean; + env?: string; + paths?: Array; +} { + let format: 'pretty' | 'json' | 'env' | 'shell' | 'json-full' = 'pretty'; + let agent = false; + let currentEnvFallback: string | undefined; + const paths: Array = []; + + for (let i = 0; i < argsAfterCommand.length; i++) { + const arg = argsAfterCommand[i]; + + if (arg === '--') break; + + if (arg === '--agent') { + agent = true; + continue; + } + + if (arg === '--env') { + const next = argsAfterCommand[i + 1]; + if (next && !next.startsWith('-')) { + currentEnvFallback = next; + } + continue; + } + + if (arg.startsWith('--env=')) { + currentEnvFallback = arg.slice('--env='.length); + continue; + } + + if (arg === '--path' || arg === '-p') { + const next = argsAfterCommand[i + 1]; + if (next && !next.startsWith('-')) { + paths.push(next); + } + continue; + } + + if (arg.startsWith('--path=')) { + paths.push(arg.slice('--path='.length)); + continue; + } + + if (arg.startsWith('-p=')) { + paths.push(arg.slice(3)); + continue; + } + + if (arg === '--format' || arg === '-f') { + const next = argsAfterCommand[i + 1]; + if (next && !next.startsWith('-')) { + const parsed = next as typeof format; + if (parsed === 'pretty' || parsed === 'json' || parsed === 'env' || parsed === 'shell' || parsed === 'json-full') { + format = parsed; + } + } + continue; + } + + if (arg.startsWith('--format=')) { + const inline = arg.slice('--format='.length) as typeof format; + if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { + format = inline; + } + continue; + } + + if (arg.startsWith('-f=')) { + const inline = arg.slice(3) as typeof format; + if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { + format = inline; + } + } + } + + return { + format, + agent, + ...(currentEnvFallback ? { env: currentEnvFallback } : {}), + ...(paths.length ? { paths } : {}), + }; +} + +async function assertLoadAllowedInProxy(argsAfterCommand: Array, env: NodeJS.ProcessEnv) { + const { + format, agent, env: currentEnvFallback, paths, + } = parseLoadSafetyArgs(argsAfterCommand); + + if (format === 'pretty' || format === 'json' || format === 'json-full') { + const expectedFingerprint = env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR]; + if (expectedFingerprint) { + let currentFingerprint: string; + try { + const graph = await loadVarlockEnvGraph({ + ...(currentEnvFallback ? { currentEnvFallback } : {}), + ...(paths ? { entryFilePaths: paths } : {}), + }); + currentFingerprint = buildProxySchemaFingerprint(graph); + } catch { + throw new CliExitError( + 'Command blocked in proxied context: schema could not be verified against approved proxy snapshot.', + { + suggestion: 'Restart `varlock run --proxy` after reviewing schema changes in a trusted context.', + }, + ); + } + + if (currentFingerprint !== expectedFingerprint) { + throw new CliExitError( + 'Command blocked in proxied context: schema changed since proxy start and is awaiting trusted approval.', + { + suggestion: 'Restart `varlock run --proxy` after reviewing and approving schema changes.', + }, + ); + } + } + } + + // pretty stays allowed (default, redacted summaries) + if (format === 'pretty') return; + + // json/json-full can be allowed in explicit agent-safe mode + if ((format === 'json' || format === 'json-full') && agent) return; + + throw new CliExitError( + `Command blocked in proxied context: \`varlock load\` with format \`${format}\` can expose raw values.`, + { + suggestion: 'Use `varlock load` (pretty) or `varlock load --agent --format json` instead.', + }, + ); +} + +export async function enforceProxyContextGuards(rawArgs: Array, env: NodeJS.ProcessEnv = process.env) { + if (!isProxyChildProcess(env)) return; + + const command = rawArgs[0]; + if (!command || command.startsWith('-')) return; + + if (COMMANDS_DENIED_IN_PROXY.has(command)) { + throw new CliExitError( + `Command blocked in proxied context: \`varlock ${command}\` is disabled to prevent secret recovery.`, + { + suggestion: 'Run this command outside `varlock run --proxy`, or request explicit user approval in a trusted context.', + }, + ); + } + + if (command === 'load') { + await assertLoadAllowedInProxy(rawArgs.slice(1), env); + } +} diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts new file mode 100644 index 000000000..d671d8f3a --- /dev/null +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; + +import type { EnvGraph } from '../../env-graph'; + +/** + * Build a deterministic fingerprint for schema-level safety controls. + * Phase 1 focuses on preventing sensitivity downgrades from taking effect + * mid-proxy-run, so we hash item keys + sensitivity/required/type metadata. + */ +export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { + const schemaShape = envGraph.sortedConfigKeys.map((key) => { + const item = envGraph.configSchema[key]; + return { + key, + isSensitive: item.isSensitive, + isRequired: item.isRequired, + dataType: item.dataType?.name ?? null, + }; + }); + + const input = JSON.stringify({ + basePath: envGraph.basePath ?? null, + schemaShape, + }); + + return createHash('sha256').update(input).digest('hex'); +} diff --git a/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts new file mode 100644 index 000000000..4f34892de --- /dev/null +++ b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts @@ -0,0 +1,130 @@ +import { + describe, expect, test, vi, beforeEach, +} from 'vitest'; + +const { + loadVarlockEnvGraphMock, + buildProxySchemaFingerprintMock, +} = vi.hoisted(() => ({ + loadVarlockEnvGraphMock: vi.fn(), + buildProxySchemaFingerprintMock: vi.fn(), +})); + +vi.mock('../../../lib/load-graph', () => ({ + loadVarlockEnvGraph: loadVarlockEnvGraphMock, +})); + +vi.mock('../proxy-schema-fingerprint', () => ({ + buildProxySchemaFingerprint: buildProxySchemaFingerprintMock, +})); + +import { CliExitError } from '../exit-error'; +import { + enforceProxyContextGuards, + parseLoadSafetyArgs, + PROXY_SCHEMA_FINGERPRINT_ENV_VAR, +} from '../proxy-context-guard'; + +describe('parseLoadSafetyArgs', () => { + test('defaults to pretty and agent=false', () => { + expect(parseLoadSafetyArgs([])).toEqual({ format: 'pretty', agent: false }); + }); + + test('parses --format json with --agent', () => { + expect(parseLoadSafetyArgs(['--format', 'json', '--agent'])).toEqual({ + format: 'json', + agent: true, + }); + }); + + test('parses inline --format=json-full', () => { + expect(parseLoadSafetyArgs(['--format=json-full'])).toEqual({ + format: 'json-full', + agent: false, + }); + }); + + test('parses short -f shell', () => { + expect(parseLoadSafetyArgs(['-f', 'shell'])).toEqual({ + format: 'shell', + agent: false, + }); + }); + + test('parses --path and --env for schema verification', () => { + expect(parseLoadSafetyArgs(['--path', './config/.env.schema', '--env', 'production'])).toEqual({ + format: 'pretty', + agent: false, + env: 'production', + paths: ['./config/.env.schema'], + }); + }); +}); + +describe('enforceProxyContextGuards', () => { + const proxiedEnv = { __VARLOCK_PROXY_CHILD: '1' } as NodeJS.ProcessEnv; + const normalEnv = {} as NodeJS.ProcessEnv; + + beforeEach(() => { + loadVarlockEnvGraphMock.mockReset(); + buildProxySchemaFingerprintMock.mockReset(); + loadVarlockEnvGraphMock.mockResolvedValue({}); + buildProxySchemaFingerprintMock.mockReturnValue('approved-fingerprint'); + }); + + test('does nothing outside proxied context', async () => { + await expect(enforceProxyContextGuards(['reveal'], normalEnv)).resolves.toBeUndefined(); + }); + + test('blocks nested run in proxied context', async () => { + await expect(enforceProxyContextGuards(['run', '--', 'echo', 'x'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + }); + + test('blocks printenv in proxied context', async () => { + await expect(enforceProxyContextGuards(['printenv', 'API_KEY'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + }); + + test('blocks reveal in proxied context', async () => { + await expect(enforceProxyContextGuards(['reveal', 'API_KEY'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + }); + + test('allows load pretty in proxied context', async () => { + await expect(enforceProxyContextGuards(['load'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['load', '--format', 'pretty'], proxiedEnv)).resolves.toBeUndefined(); + }); + + test('blocks load json without --agent in proxied context', async () => { + await expect(enforceProxyContextGuards(['load', '--format', 'json'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + }); + + test('allows load json with --agent in proxied context', async () => { + await expect(enforceProxyContextGuards(['load', '--format', 'json', '--agent'], proxiedEnv)).resolves.toBeUndefined(); + }); + + test('blocks load shell/env in proxied context', async () => { + await expect(enforceProxyContextGuards(['load', '--format', 'shell'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + await expect(enforceProxyContextGuards(['load', '--format', 'env'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + }); + + test('blocks load when schema fingerprint no longer matches approved snapshot', async () => { + const envWithFingerprint = { + ...proxiedEnv, + [PROXY_SCHEMA_FINGERPRINT_ENV_VAR]: 'approved-fingerprint', + } as NodeJS.ProcessEnv; + + buildProxySchemaFingerprintMock.mockReturnValue('different-fingerprint'); + + await expect(enforceProxyContextGuards(['load'], envWithFingerprint)).rejects.toBeInstanceOf(CliExitError); + }); + + test('allows load when schema fingerprint matches approved snapshot', async () => { + const envWithFingerprint = { + ...proxiedEnv, + [PROXY_SCHEMA_FINGERPRINT_ENV_VAR]: 'approved-fingerprint', + } as NodeJS.ProcessEnv; + + buildProxySchemaFingerprintMock.mockReturnValue('approved-fingerprint'); + + await expect(enforceProxyContextGuards(['load'], envWithFingerprint)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 0b6417bc9..6a53be25f 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -458,13 +458,13 @@ export class ConfigItem { _isSensitive: boolean = true; _sensitiveExplicitlySet = false; /** how sensitivity was determined (undefined = the global default that items are sensitive) */ - _sensitiveSource?: 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix'; + _sensitiveSource?: 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix' | 'proxy'; get isSensitive(): boolean { return this._isSensitive; } /** how this item's sensitivity was determined — used by `varlock explain` */ - get sensitiveSource(): 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix' | 'default' { + get sensitiveSource(): 'explicit' | 'data-type' | 'resolver' | 'default-decorator' | 'prefix' | 'proxy' | 'default' { return this._sensitiveSource ?? 'default'; } @@ -486,6 +486,20 @@ export class ConfigItem { return this._preventLeaks; } private async processSensitive() { + // Resolve the normal sensitivity signals first (so @sensitive/@public schema + // validation still runs), then force sensitivity for @proxy-managed items below. + await this.resolveSensitiveSource(); + + // @proxy-managed items are always sensitive: the proxied child only ever sees a + // placeholder while the real value is injected at the wire, so force sensitivity + // regardless of any @public / @sensitive=false signal. + if (this.getDecFns('proxy').length > 0) { + this._isSensitive = true; + this._sensitiveSource = 'proxy'; + } + } + + private async resolveSensitiveSource() { const sensitiveFromDataType = this.dataType?.isSensitive; // Pass 1: explicit per-item @sensitive / @public decorators take highest priority diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index d5e62d790..93454aab1 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -31,6 +31,9 @@ type EnvGraphDataTypeDef MaybePromise<(true | undefined | void | Error | Array)>; + /** optional placeholder generator used by proxy mode */ + generatePlaceholder?: (value: CoerceReturnType | string | undefined) => string | undefined; + // asyncValidate? - async validation function, meant to be called more sparingly // for example, when could validate an API key is currently valid @@ -70,6 +73,9 @@ export class EnvGraphDataType { get isSensitive() { return this.def.sensitive; } get isInternal() { return this.def.internal; } get docsEntries() { return this.def.docs; } + generatePlaceholder(val: any) { + return this.def.generatePlaceholder?.(val); + } /** @internal */ get _rawDef() { return this.def; } diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 3d4794d05..932d82087 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -1,7 +1,7 @@ /// import _ from '@env-spec/utils/my-dash'; import { - ParsedEnvSpecFunctionCall, ParsedEnvSpecStaticValue, + ParsedEnvSpecFunctionArgs, ParsedEnvSpecFunctionCall, ParsedEnvSpecStaticValue, parseEnvSpecDotEnvFile, type ParsedEnvSpecDecorator, } from '@env-spec/parser'; @@ -133,10 +133,16 @@ export abstract class DecoratorInstance { // so instead we just make a new dummy resolver holding the args if ( this.decoratorDef.useFnArgsResolver - && this.parsedDecorator.value instanceof ParsedEnvSpecFunctionCall + && ( + this.parsedDecorator.value instanceof ParsedEnvSpecFunctionCall + || this.parsedDecorator.value instanceof ParsedEnvSpecFunctionArgs + ) ) { + const fnArgsValue = this.parsedDecorator.value instanceof ParsedEnvSpecFunctionCall + ? this.parsedDecorator.value.data.args + : this.parsedDecorator.value; this._decValueResolver = convertParsedValueToResolvers( - this.parsedDecorator.value.data.args, + fnArgsValue, this.dataSource, this.graph.registeredResolverFunctions, ); @@ -361,6 +367,36 @@ export const builtInRootDecorators: Array> = [ { name: 'disableProcessEnvInjection', }, + { + name: 'enableProxy', + isFunction: true, + useFnArgsResolver: true, + process: (argsVal) => { + const egressResolver = argsVal.objArgs?.egress; + if (!egressResolver || !egressResolver.isStatic) return; + const egressValue = egressResolver.staticValue; + if (egressValue !== 'permissive' && egressValue !== 'strict') { + throw new SchemaError('@enableProxy: egress must be "permissive" or "strict"'); + } + }, + }, + { + name: 'proxy', + isFunction: true, + useFnArgsResolver: true, + process: (argsVal) => { + const domainResolver = argsVal.objArgs?.domain; + if (!domainResolver) { + throw new SchemaError('@proxy: missing required "domain" option'); + } + + for (const arg of argsVal.arrArgs || []) { + if (arg.fnName !== 'ref') { + throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); + } + } + }, + }, { name: 'auditIgnorePaths', isFunction: true, @@ -548,6 +584,31 @@ export const builtInItemDecorators: Array> = [ { name: 'auditIgnore', }, + { + name: 'placeholder', + process: (decVal) => { + if (!decVal.isStatic || !_.isString(decVal.staticValue)) { + throw new SchemaError('@placeholder must be a static string value'); + } + }, + }, + { + name: 'proxy', + isFunction: true, + useFnArgsResolver: true, + process: (argsVal) => { + const domainResolver = argsVal.objArgs?.domain; + if (!domainResolver) { + throw new SchemaError('@proxy: missing required "domain" option'); + } + + for (const arg of argsVal.arrArgs || []) { + if (arg.fnName !== 'ref') { + throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); + } + } + }, + }, // test-only decorators — dropped in release builds ...__VARLOCK_BUILD_TYPE__ === 'test' ? [ diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 33353a6cf..45f7f8c41 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -14,7 +14,11 @@ import { ResolutionError, SchemaError } from './errors'; import { generateTypes } from './type-generation'; import { - builtInItemDecorators, builtInRootDecorators, RootDecoratorInstance, type ItemDecoratorDef, type RootDecoratorDef, + builtInItemDecorators, builtInRootDecorators, + ItemDecoratorInstance, + RootDecoratorInstance, + type ItemDecoratorDef, + type RootDecoratorDef, } from './decorators'; import { getErrorLocation } from './error-location'; import type { VarlockPlugin } from './plugins'; @@ -23,6 +27,8 @@ import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info'; import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; import { isVarlockReservedKey } from './reserved-vars'; import { buildOverrideProvenanceMetadata, type OverrideProvenanceMetadata } from '../../lib/injected-env-provenance'; +import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; +import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from '../../proxy/types'; const processExists = !!globalThis.process; const originalProcessEnv = { ...processExists && process.env }; @@ -55,6 +61,8 @@ export type SerializedEnvGraph = { preventLeaks?: boolean; encryptInjectedEnv?: boolean; disableProcessEnvInjection?: boolean; + enableProxy?: boolean; + proxyEgress?: ProxyEgressMode; }, config: Record d.resolve())); } get graphAdjacencyList() { @@ -753,6 +763,13 @@ export class EnvGraph { serializedGraph.settings.preventLeaks = this.getRootDec('preventLeaks')?.resolvedValue ?? true; serializedGraph.settings.encryptInjectedEnv = this.getRootDec('encryptInjectedEnv')?.resolvedValue ?? false; serializedGraph.settings.disableProcessEnvInjection = this.getRootDec('disableProcessEnvInjection')?.resolvedValue ?? false; + const enableProxy = this.getRootDecFns('enableProxy')[0]?.resolvedValue; + serializedGraph.settings.enableProxy = !!enableProxy; + if (enableProxy?.obj?.egress === 'strict') { + serializedGraph.settings.proxyEgress = 'strict'; + } else { + serializedGraph.settings.proxyEgress = 'permissive'; + } // collect all errors into a single nested object const errors: SerializedEnvGraphErrors = {}; @@ -859,4 +876,96 @@ export class EnvGraph { /** plugins installed globally in the graph */ plugins: Array = []; + + private static normalizeDomainList(domainValue: unknown): Array { + if (!_.isString(domainValue)) return []; + return domainValue + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + } + + private static collectRefItemKeysFromResolverArgs(dec: RootDecoratorInstance | ItemDecoratorInstance): Array { + const arrArgs = dec?.decValueResolver?.arrArgs; + if (!arrArgs) return []; + + const itemKeys: Array = []; + for (const arg of arrArgs) { + if (arg.fnName === 'ref' && _.isString(arg.arrArgs?.[0]?.staticValue)) { + itemKeys.push(arg.arrArgs[0].staticValue); + } + } + return itemKeys; + } + + async getProxyRules(): Promise> { + const rules: Array = []; + + // detached rules from root-level @proxy(...) + for (const rootProxyDec of this.getRootDecFns('proxy')) { + const resolved = await rootProxyDec.resolve(); + const domain = EnvGraph.normalizeDomainList(resolved?.obj?.domain); + if (domain.length === 0) continue; + + rules.push({ + source: 'detached', + domain, + itemKeys: EnvGraph.collectRefItemKeysFromResolverArgs(rootProxyDec), + ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), + ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), + ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), + ...(_.isString(resolved?.obj?.sign) ? { sign: resolved.obj.sign } : {}), + ...(_.isString(resolved?.obj?.transform) ? { transform: resolved.obj.transform } : {}), + }); + } + + // attached rules from item-level @proxy(...) + for (const itemKey of this.sortedConfigKeys) { + const item = this.configSchema[itemKey]; + for (const itemProxyDec of item.getDecFns('proxy')) { + const resolved = await itemProxyDec.resolve(); + const domain = EnvGraph.normalizeDomainList(resolved?.obj?.domain); + if (domain.length === 0) continue; + + const detachedItemKeys = EnvGraph.collectRefItemKeysFromResolverArgs(itemProxyDec); + const itemKeys = _.uniq([itemKey, ...detachedItemKeys]); + rules.push({ + source: 'attached', + domain, + itemKeys, + ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), + ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), + ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), + ...(_.isString(resolved?.obj?.sign) ? { sign: resolved.obj.sign } : {}), + ...(_.isString(resolved?.obj?.transform) ? { transform: resolved.obj.transform } : {}), + }); + } + } + + return rules; + } + + async getProxyManagedItems(): Promise> { + const rules = await this.getProxyRules(); + const managedKeys = _.uniq(rules.flatMap((r) => r.itemKeys)); + const managedItems: Array = []; + + const usedPlaceholders = new Set(); + for (const key of managedKeys) { + const item = this.configSchema[key]; + if (!item) { + throw new SchemaError(`@proxy references unknown item "${key}"`); + } + if (!_.isString(item.resolvedValue) || item.resolvedValue.length === 0) continue; + + const placeholder = await generateProxyPlaceholderForItem(item, usedPlaceholders); + managedItems.push({ + key, + placeholder, + realValue: item.resolvedValue, + }); + } + + return managedItems; + } } diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts new file mode 100644 index 000000000..2d986c5f1 --- /dev/null +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; +import outdent from 'outdent'; +import { DotEnvFileDataSource, EnvGraph } from '../index'; + +async function loadGraph(envFile: string) { + const graph = new EnvGraph(); + const source = new DotEnvFileDataSource('.env.schema', { overrideContents: envFile }); + await graph.setRootDataSource(source); + await graph.finishLoad(); + await graph.resolveEnvValues(); + return graph; +} + +describe('proxy decorators', () => { + test('item @proxy implies sensitive', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + API_KEY=secret-value + `); + + const item = graph.configSchema.API_KEY; + expect(item.isSensitive).toBe(true); + }); + + test('collects attached and detached proxy rules', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # @proxy(domain="api.example.com") + # --- + BASELINE=1 + + # @proxy(domain="api.stripe.com") + STRIPE_KEY=sk_live_real + + DETACHED_KEY=detached-secret + `); + + const rules = await graph.getProxyRules(); + expect(rules).toMatchObject([ + { + source: 'detached', + domain: ['api.example.com'], + itemKeys: [], + }, + ]); + }); + + test('proxy managed items generate placeholders by priority', async () => { + const graph = await loadGraph(outdent` + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + # @placeholder=sk_test_explicit + EXPLICIT_KEY=sk_live_real_explicit + + # @proxy(domain="api.example.com") + # @example=ghp_ABCD1234WXYZ + EXAMPLE_KEY=ghp_REAL_SECRET + + # @proxy(domain="api.example.com") + # @type=string(startsWith=tok_, isLength=12) + TYPE_KEY=tok_real_secret + `); + + const managed = await graph.getProxyManagedItems(); + const byKey = Object.fromEntries(managed.map((item) => [item.key, item])); + + expect(byKey.EXPLICIT_KEY?.placeholder).toBe('sk_test_explicit'); + expect(byKey.EXAMPLE_KEY?.placeholder).toBe('ghp_000000000000'); + expect(byKey.TYPE_KEY?.placeholder).toBe('tok_00000000'); + + expect(byKey.EXPLICIT_KEY?.realValue).toBe('sk_live_real_explicit'); + expect(byKey.EXAMPLE_KEY?.realValue).toBe('ghp_REAL_SECRET'); + expect(byKey.TYPE_KEY?.realValue).toBe('tok_real_secret'); + }); +}); diff --git a/packages/varlock/src/proxy/placeholder.ts b/packages/varlock/src/proxy/placeholder.ts new file mode 100644 index 000000000..5ff6405e2 --- /dev/null +++ b/packages/varlock/src/proxy/placeholder.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto'; +import _ from '@env-spec/utils/my-dash'; +import type { ConfigItem } from '../env-graph/lib/config-item'; + +function shortHash(input: string) { + return createHash('sha256').update(input).digest('hex').slice(0, 8); +} + +function fromExampleTemplate(example: string): string { + const lastUnderscoreIdx = example.lastIndexOf('_'); + if (lastUnderscoreIdx >= 0) { + const prefix = example.slice(0, lastUnderscoreIdx + 1); + const suffix = example.slice(lastUnderscoreIdx + 1).replace(/[A-Za-z0-9]/g, '0'); + return `${prefix}${suffix}`; + } + + const firstDigitIdx = example.search(/\d/); + if (firstDigitIdx > 0) { + const prefix = example.slice(0, firstDigitIdx); + const suffix = example.slice(firstDigitIdx).replace(/[A-Za-z0-9]/g, '0'); + return `${prefix}${suffix}`; + } + + return example.replace(/[A-Za-z0-9]/g, '0'); +} + +function fromTypeSettings(item: ConfigItem): string | undefined { + const typeDecParsedValue = item.getDec('type')?.parsedDecorator.value; + if (!typeDecParsedValue || !('simplifiedArgs' in typeDecParsedValue)) return undefined; + + const simplifiedArgs = typeDecParsedValue.simplifiedArgs; + if (!_.isPlainObject(simplifiedArgs)) return undefined; + + const startsWith = _.isString(simplifiedArgs.startsWith) + ? simplifiedArgs.startsWith + : ''; + const isLength = _.isNumber(simplifiedArgs.isLength) + ? simplifiedArgs.isLength + : undefined; + + if (!startsWith && !isLength) return undefined; + + if (isLength !== undefined) { + if (isLength <= 0) return ''; + if (startsWith.length >= isLength) return startsWith.slice(0, isLength); + return `${startsWith}${'0'.repeat(isLength - startsWith.length)}`; + } + + return `${startsWith}0000000000000000`; +} + +function buildFallbackPlaceholder(itemKey: string): string { + return `vlk_placeholder_${itemKey}_${shortHash(itemKey)}`; +} + +function ensureUnique(placeholder: string, usedPlaceholders: Set) { + if (!usedPlaceholders.has(placeholder)) { + usedPlaceholders.add(placeholder); + return placeholder; + } + + let i = 1; + while (true) { + const next = `${placeholder}_${i}`; + if (!usedPlaceholders.has(next)) { + usedPlaceholders.add(next); + return next; + } + i += 1; + } +} + +export async function generateProxyPlaceholderForItem( + item: ConfigItem, + usedPlaceholders: Set, +): Promise { + const placeholderDec = item.getDec('placeholder'); + if (placeholderDec) { + const explicitPlaceholder = await placeholderDec.resolve(); + if (_.isString(explicitPlaceholder) && explicitPlaceholder.length > 0) { + return ensureUnique(explicitPlaceholder, usedPlaceholders); + } + } + + const generatedByType = item.dataType?.generatePlaceholder(item.resolvedValue); + if (_.isString(generatedByType) && generatedByType.length > 0) { + return ensureUnique(generatedByType, usedPlaceholders); + } + + const exampleDec = item.getDec('example'); + if (exampleDec) { + const exampleValue = await exampleDec.resolve(); + if (_.isString(exampleValue) && exampleValue.length > 0) { + return ensureUnique(fromExampleTemplate(exampleValue), usedPlaceholders); + } + } + + const fromType = fromTypeSettings(item); + if (fromType) { + return ensureUnique(fromType, usedPlaceholders); + } + + return ensureUnique(buildFallbackPlaceholder(item.key), usedPlaceholders); +} diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts new file mode 100644 index 000000000..7c10b30d2 --- /dev/null +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest'; +import http from 'node:http'; +import { URL } from 'node:url'; + +import { startLocalProxyRuntime } from './runtime-proxy'; + +async function requestViaProxy(proxyUrl: string, targetUrl: string) { + const proxy = new URL(proxyUrl); + return await new Promise<{ statusCode: number; body: string }>((resolve, reject) => { + const req = http.request({ + host: proxy.hostname, + port: Number(proxy.port), + method: 'GET', + path: targetUrl, + }, (res) => { + const chunks: Array = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('startLocalProxyRuntime', () => { + test('returns proxy env vars and can be stopped', async () => { + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [], + egressMode: 'permissive', + }); + + expect(runtime.env.HTTP_PROXY).toBeDefined(); + expect(runtime.env.HTTPS_PROXY).toBe(runtime.env.HTTP_PROXY); + expect(runtime.env.ALL_PROXY).toBe(runtime.env.HTTP_PROXY); + expect(runtime.env.http_proxy).toBe(runtime.env.HTTP_PROXY); + expect(runtime.env.https_proxy).toBe(runtime.env.HTTP_PROXY); + expect(runtime.env.all_proxy).toBe(runtime.env.HTTP_PROXY); + + expect(runtime.env.NODE_EXTRA_CA_CERTS).toBeDefined(); + expect(runtime.env.SSL_CERT_FILE).toBeDefined(); + expect(runtime.env.REQUESTS_CA_BUNDLE).toBeDefined(); + expect(runtime.env.CURL_CA_BUNDLE).toBeDefined(); + expect(runtime.env.GIT_SSL_CAINFO).toBeDefined(); + + await runtime.stop(); + }); + + test('blocks non-proxy domains in strict egress mode', async () => { + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [], + egressMode: 'strict', + }); + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://example.com/'); + expect(response.statusCode).toBe(403); + expect(response.body).toContain('strict mode'); + await runtime.stop(); + }); +}); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts new file mode 100644 index 000000000..8e5c74deb --- /dev/null +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -0,0 +1,463 @@ +import { execFileSync } from 'node:child_process'; +import { + mkdtemp, readFile, rm, writeFile, +} from 'node:fs/promises'; +import http from 'node:http'; +import https from 'node:https'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import tls from 'node:tls'; +import { URL } from 'node:url'; + +import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from './types'; + +const LOCALHOST = '127.0.0.1'; + +export type ProxyRuntimeContext = { + env: NodeJS.ProcessEnv; + stop: () => Promise; +}; + +export type StartLocalProxyRuntimeInput = { + managedItems: Array; + rules: Array; + egressMode: ProxyEgressMode; +}; + +type HostInfo = { host: string, port: number }; + +function parseHostPort(value: string): HostInfo | null { + const [host, portRaw] = value.split(':'); + if (!host) return null; + const port = Number(portRaw ?? 443); + if (Number.isNaN(port)) return null; + return { host, port }; +} + +function normalizeHost(host: string): string { + return host.toLowerCase().trim(); +} + +function domainMatches(domainPattern: string, host: string): boolean { + const pattern = normalizeHost(domainPattern); + const normalizedHost = normalizeHost(host); + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(2); + return normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`); + } + return normalizedHost === pattern; +} + +function hostMatchesProxyRules(host: string, rules: Array): boolean { + return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); +} + +function replaceUsingManagedItems(value: string, managedItems: Array): string { + let next = value; + for (const item of managedItems) { + if (item.placeholder) { + next = next.split(item.placeholder).join(item.realValue); + } + } + return next; +} + +function transformHeaders( + headers: http.IncomingHttpHeaders, + managedItems: Array, +): Record> { + const out: Record> = {}; + for (const [key, val] of Object.entries(headers)) { + if (val === undefined) continue; + if (Array.isArray(val)) { + out[key] = val.map((v) => replaceUsingManagedItems(v, managedItems)); + } else { + out[key] = replaceUsingManagedItems(String(val), managedItems); + } + } + return out; +} + +function sanitizeHostForFilename(host: string): string { + return host.replaceAll(/[^a-zA-Z0-9.-]/g, '_'); +} + +function runOpenssl(args: Array) { + execFileSync('openssl', args, { + stdio: 'ignore', + }); +} + +async function createProxyCa(certsDir: string): Promise<{ + caKeyPath: string; + caCertPath: string; + combinedCaPath: string; +}> { + const caKeyPath = path.join(certsDir, 'ca-key.pem'); + const caCertPath = path.join(certsDir, 'ca-cert.pem'); + const combinedCaPath = path.join(certsDir, 'combined-ca.pem'); + + runOpenssl([ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-nodes', + '-days', + '3', + '-keyout', + caKeyPath, + '-out', + caCertPath, + '-subj', + '/CN=varlock-proxy-ca', + ]); + + const [caCertContents] = await Promise.all([readFile(caCertPath, 'utf8')]); + const combined = `${caCertContents}\n${tls.rootCertificates.join('\n')}\n`; + await writeFile(combinedCaPath, combined, 'utf8'); + + return { caKeyPath, caCertPath, combinedCaPath }; +} + +async function createHostCert( + certsDir: string, + host: string, + caKeyPath: string, + caCertPath: string, +): Promise<{ + key: Buffer; + cert: Buffer; + context: tls.SecureContext; +}> { + const safeHost = sanitizeHostForFilename(host); + const hostKeyPath = path.join(certsDir, `${safeHost}.key.pem`); + const hostCsrPath = path.join(certsDir, `${safeHost}.csr.pem`); + const hostCertPath = path.join(certsDir, `${safeHost}.cert.pem`); + const extPath = path.join(certsDir, `${safeHost}.ext.cnf`); + const serialPath = path.join(certsDir, 'ca.srl'); + + await writeFile(extPath, `subjectAltName=DNS:${host}\n`, 'utf8'); + + runOpenssl([ + 'req', + '-new', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + hostKeyPath, + '-out', + hostCsrPath, + '-subj', + `/CN=${host}`, + ]); + + runOpenssl([ + 'x509', + '-req', + '-in', + hostCsrPath, + '-CA', + caCertPath, + '-CAkey', + caKeyPath, + '-CAserial', + serialPath, + '-CAcreateserial', + '-out', + hostCertPath, + '-days', + '3', + '-sha256', + '-extfile', + extPath, + ]); + + const [key, cert] = await Promise.all([ + readFile(hostKeyPath), + readFile(hostCertPath), + ]); + return { + key, + cert, + context: tls.createSecureContext({ key, cert }), + }; +} + +async function readBody(req: http.IncomingMessage): Promise { + const chunks: Array = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function buildPathnameAndQuery(input: string, managedItems: Array): string { + return replaceUsingManagedItems(input, managedItems); +} + +/** + * Local MITM proxy runtime for `varlock run --proxy`. + * Rewrites placeholder values to real values for requests matching @proxy domains. + */ +export async function startLocalProxyRuntime({ + managedItems, + rules, + egressMode, +}: StartLocalProxyRuntimeInput): Promise { + const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); + const { caKeyPath, caCertPath, combinedCaPath } = await createProxyCa(certsDir); + + const handleInterceptRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => { + const hostHeader = req.headers.host ?? ''; + const hostInfo = parseHostPort(hostHeader.includes(':') ? hostHeader : `${hostHeader}:443`); + if (!hostInfo) { + res.statusCode = 400; + res.end('Invalid host'); + return; + } + + const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); + const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; + if (!shouldAllowEgress) { + res.statusCode = 403; + res.end('Proxy egress blocked by strict mode'); + return; + } + const body = await readBody(req); + const bodyString = body.toString('utf8'); + const rewrittenBody = shouldRewrite ? replaceUsingManagedItems(bodyString, managedItems) : bodyString; + + const upstreamHeaders = transformHeaders(req.headers, shouldRewrite ? managedItems : []); + delete upstreamHeaders['proxy-connection']; + delete upstreamHeaders.connection; + + const rewrittenPath = shouldRewrite + ? buildPathnameAndQuery(req.url ?? '/', managedItems) + : (req.url ?? '/'); + + if (rewrittenBody.length !== body.length) { + upstreamHeaders['content-length'] = String(Buffer.byteLength(rewrittenBody)); + } + + const upstreamReq = https.request({ + protocol: 'https:', + hostname: hostInfo.host, + port: hostInfo.port || 443, + method: req.method, + path: rewrittenPath, + headers: upstreamHeaders, + }, (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + }); + + upstreamReq.on('error', () => { + if (!res.headersSent) res.statusCode = 502; + res.end('Upstream MITM request failed'); + }); + upstreamReq.end(rewrittenBody); + }; + + const hostMitmServers = new Map(); + const getOrCreateHostMitmServer = async (host: string): Promise<{ server: https.Server; port: number }> => { + const normalized = normalizeHost(host); + const cached = hostMitmServers.get(normalized); + if (cached) return cached; + + const hostCert = await createHostCert(certsDir, normalized, caKeyPath, caCertPath); + const server = https.createServer({ + key: hostCert.key, + cert: hostCert.cert, + ALPNProtocols: ['http/1.1'], + }, (req, res) => { + handleInterceptRequest(req, res).catch(() => { + if (!res.headersSent) res.statusCode = 502; + res.end('Upstream MITM request failed'); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, LOCALHOST, () => { + server.off('error', reject); + resolve(); + }); + }); + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(); + throw new Error(`Failed to start MITM TLS server for ${normalized}`); + } + + const created = { server, port: addr.port }; + hostMitmServers.set(normalized, created); + return created; + }; + + // Handles absolute-form proxy requests (mostly plain HTTP). + const proxyServer = http.createServer(async (clientReq, clientRes) => { + const urlRaw = clientReq.url; + if (!urlRaw) { + clientRes.statusCode = 400; + clientRes.end('Missing request URL'); + return; + } + + let destination: URL; + try { + destination = new URL(urlRaw); + } catch { + clientRes.statusCode = 400; + clientRes.end('Invalid proxy request URL'); + return; + } + + const shouldRewrite = hostMatchesProxyRules(destination.hostname, rules); + const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; + if (!shouldAllowEgress) { + clientRes.statusCode = 403; + clientRes.end('Proxy egress blocked by strict mode'); + return; + } + const isHttps = destination.protocol === 'https:'; + + const body = await readBody(clientReq); + const bodyString = body.toString('utf8'); + const rewrittenBody = shouldRewrite ? replaceUsingManagedItems(bodyString, managedItems) : bodyString; + + const rewrittenPath = shouldRewrite + ? buildPathnameAndQuery(`${destination.pathname}${destination.search}`, managedItems) + : `${destination.pathname}${destination.search}`; + + const upstreamHeaders = transformHeaders(clientReq.headers, shouldRewrite ? managedItems : []); + delete upstreamHeaders['proxy-connection']; + delete upstreamHeaders.connection; + upstreamHeaders.host = destination.host; + + if (rewrittenBody.length !== body.length) { + upstreamHeaders['content-length'] = String(Buffer.byteLength(rewrittenBody)); + } + + const upstream = (isHttps ? https : http).request({ + protocol: destination.protocol, + hostname: destination.hostname, + port: destination.port || (isHttps ? 443 : 80), + method: clientReq.method, + path: rewrittenPath, + headers: upstreamHeaders, + }, (upstreamRes) => { + clientRes.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(clientRes); + }); + + upstream.on('error', () => { + if (!clientRes.headersSent) clientRes.statusCode = 502; + clientRes.end('Upstream proxy error'); + }); + upstream.end(rewrittenBody); + }); + + proxyServer.on('connect', async (req, clientSocket, head) => { + const hostInfo = parseHostPort(req.url ?? ''); + if (!hostInfo) { + clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); + clientSocket.destroy(); + return; + } + + const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); + const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; + if (!shouldAllowEgress) { + clientSocket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 34\r\nConnection: close\r\n\r\nProxy egress blocked by strict mode'); + clientSocket.destroy(); + return; + } + + // Only MITM for configured proxy domains. Others are tunneled through. + if (!shouldRewrite) { + const upstreamSocket = net.connect(hostInfo.port, hostInfo.host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length > 0) upstreamSocket.write(head); + clientSocket.pipe(upstreamSocket); + upstreamSocket.pipe(clientSocket); + }); + upstreamSocket.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstreamSocket.destroy()); + return; + } + + try { + const hostMitmServer = await getOrCreateHostMitmServer(hostInfo.host); + const mitmSocket = net.connect(hostMitmServer.port, LOCALHOST, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length > 0) { + mitmSocket.write(head); + } + clientSocket.pipe(mitmSocket); + mitmSocket.pipe(clientSocket); + }); + mitmSocket.on('error', () => { + clientSocket.destroy(); + }); + clientSocket.on('error', () => { + mitmSocket.destroy(); + }); + } catch { + clientSocket.destroy(); + } + }); + + await new Promise((resolve, reject) => { + proxyServer.once('error', reject); + proxyServer.listen(0, LOCALHOST, () => { + proxyServer.off('error', reject); + resolve(); + }); + }); + + const address = proxyServer.address(); + if (!address || typeof address === 'string') { + await new Promise((resolve) => { + proxyServer.close(() => resolve()); + }); + throw new Error('Failed to start local proxy runtime'); + } + const proxyUrl = `http://${LOCALHOST}:${address.port}`; + + return { + env: { + HTTP_PROXY: proxyUrl, + HTTPS_PROXY: proxyUrl, + ALL_PROXY: proxyUrl, + http_proxy: proxyUrl, + https_proxy: proxyUrl, + all_proxy: proxyUrl, + NO_PROXY: 'localhost,127.0.0.1,::1', + no_proxy: 'localhost,127.0.0.1,::1', + NODE_EXTRA_CA_CERTS: caCertPath, + SSL_CERT_FILE: combinedCaPath, + REQUESTS_CA_BUNDLE: combinedCaPath, + CURL_CA_BUNDLE: combinedCaPath, + GIT_SSL_CAINFO: combinedCaPath, + }, + stop: async () => { + await Promise.all([ + new Promise((resolve) => { + proxyServer.close(() => resolve()); + }), + new Promise((resolve) => { + Promise.all( + [...hostMitmServers.values()].map(({ server }) => new Promise((innerResolve) => { + server.close(() => innerResolve()); + })), + ).then(() => resolve()); + }), + ]); + await rm(certsDir, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts new file mode 100644 index 000000000..f2548d76b --- /dev/null +++ b/packages/varlock/src/proxy/types.ts @@ -0,0 +1,20 @@ +export type ProxyEgressMode = 'permissive' | 'strict'; + +export type ProxyRuleSource = 'attached' | 'detached'; + +export type ProxyRule = { + source: ProxyRuleSource; + domain: Array; + itemKeys: Array; + path?: string; + method?: string; + block?: boolean; + sign?: string; + transform?: string; +}; + +export type ProxyManagedItem = { + key: string; + placeholder: string; + realValue: string; +}; From 23f68a6c76ba8fae67e770c4cb1709f55f2cea2a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 6 Jun 2026 00:12:42 -0700 Subject: [PATCH 02/64] Fix proxy guard typecheck and add bumpy changeset --- .bumpy/proxy-phase1-guardrails.md | 5 +++++ .../varlock/src/cli/helpers/proxy-context-guard.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .bumpy/proxy-phase1-guardrails.md diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md new file mode 100644 index 000000000..41b1f7205 --- /dev/null +++ b/.bumpy/proxy-phase1-guardrails.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Add proxy guardrails and strict egress enforcement for run --proxy diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index 8cf5bff4f..83c568157 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -6,6 +6,7 @@ export const PROXY_CHILD_ENV_VAR = '__VARLOCK_PROXY_CHILD'; export const PROXY_SCHEMA_FINGERPRINT_ENV_VAR = '__VARLOCK_PROXY_SCHEMA_FINGERPRINT'; const COMMANDS_DENIED_IN_PROXY = new Set(['run', 'printenv', 'reveal']); +type LoadOutputFormat = 'pretty' | 'json' | 'env' | 'shell' | 'json-full'; export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boolean { return env[PROXY_CHILD_ENV_VAR] === '1'; @@ -16,12 +17,12 @@ export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boole * We only care about format + agent safety flags. */ export function parseLoadSafetyArgs(argsAfterCommand: Array): { - format: 'pretty' | 'json' | 'env' | 'shell' | 'json-full'; + format: LoadOutputFormat; agent: boolean; env?: string; paths?: Array; } { - let format: 'pretty' | 'json' | 'env' | 'shell' | 'json-full' = 'pretty'; + let format: LoadOutputFormat = 'pretty'; let agent = false; let currentEnvFallback: string | undefined; const paths: Array = []; @@ -70,7 +71,7 @@ export function parseLoadSafetyArgs(argsAfterCommand: Array): { if (arg === '--format' || arg === '-f') { const next = argsAfterCommand[i + 1]; if (next && !next.startsWith('-')) { - const parsed = next as typeof format; + const parsed = next as LoadOutputFormat; if (parsed === 'pretty' || parsed === 'json' || parsed === 'env' || parsed === 'shell' || parsed === 'json-full') { format = parsed; } @@ -79,7 +80,7 @@ export function parseLoadSafetyArgs(argsAfterCommand: Array): { } if (arg.startsWith('--format=')) { - const inline = arg.slice('--format='.length) as typeof format; + const inline = arg.slice('--format='.length) as LoadOutputFormat; if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { format = inline; } @@ -87,7 +88,7 @@ export function parseLoadSafetyArgs(argsAfterCommand: Array): { } if (arg.startsWith('-f=')) { - const inline = arg.slice(3) as typeof format; + const inline = arg.slice(3) as LoadOutputFormat; if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { format = inline; } From 73ec833e0eb0b64c2afcd48b1de4e80b48bf9cbd Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sun, 7 Jun 2026 23:09:16 -0700 Subject: [PATCH 03/64] feat(varlock): add proxy sessions and placeholder-safe proxy behavior --- packages/varlock/src/cli/cli-executable.ts | 2 + .../varlock/src/cli/commands/load.command.ts | 14 + .../varlock/src/cli/commands/proxy.command.ts | 684 ++++++++++++++++++ .../varlock/src/cli/commands/run.command.ts | 65 +- .../cli/commands/test/proxy.command.test.ts | 50 ++ .../src/cli/helpers/proxy-context-guard.ts | 173 +---- .../helpers/test/proxy-context-guard.test.ts | 113 +-- .../varlock/src/env-graph/lib/decorators.ts | 3 + packages/varlock/src/lib/load-graph.ts | 23 +- .../varlock/src/lib/test/load-graph.test.ts | 54 ++ packages/varlock/src/proxy/env-vars.ts | 5 + .../varlock/src/proxy/runtime-proxy.test.ts | 60 +- packages/varlock/src/proxy/runtime-proxy.ts | 171 ++++- .../varlock/src/proxy/session-registry.ts | 269 +++++++ 14 files changed, 1360 insertions(+), 326 deletions(-) create mode 100644 packages/varlock/src/cli/commands/proxy.command.ts create mode 100644 packages/varlock/src/cli/commands/test/proxy.command.test.ts create mode 100644 packages/varlock/src/lib/test/load-graph.test.ts create mode 100644 packages/varlock/src/proxy/env-vars.ts create mode 100644 packages/varlock/src/proxy/session-registry.ts diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 916ebf89c..86eb139b0 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -33,6 +33,7 @@ import { commandSpec as auditCommandSpec } from './commands/audit.command'; import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.command'; import { commandSpec as cacheCommandSpec } from './commands/cache.command'; import { commandSpec as keychainCommandSpec } from './commands/keychain.command'; +import { commandSpec as proxyCommandSpec } from './commands/proxy.command'; // import { commandSpec as loginCommandSpec } from './commands/login.command'; // import { commandSpec as pluginCommandSpec } from './commands/plugin.command'; @@ -77,6 +78,7 @@ subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, asy subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async () => await import('./commands/generate-key.command'))); subCommands.set('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.command'))); subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => await import('./commands/keychain.command'))); +subCommands.set('proxy', buildLazyCommand(proxyCommandSpec, async () => await import('./commands/proxy.command'))); // subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command'))); // subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command'))); diff --git a/packages/varlock/src/cli/commands/load.command.ts b/packages/varlock/src/cli/commands/load.command.ts index e1899653a..75ccbe57b 100644 --- a/packages/varlock/src/cli/commands/load.command.ts +++ b/packages/varlock/src/cli/commands/load.command.ts @@ -10,6 +10,11 @@ import { } from '../helpers/error-checks'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import ansis from 'ansis'; +import { + PROXY_CHILD_ENV_VAR, + PROXY_SESSION_ID_ENV_VAR, + PROXY_SESSION_UUID_ENV_VAR, +} from '../../proxy/env-vars'; export const commandSpec = define({ name: 'load', @@ -192,6 +197,15 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = // this is an inspection dump (not the injected blob), so include @internal items — // they're flagged with isInternal and redacted below like any other sensitive value const serialized = envGraph.getSerializedGraph({ includeInternal: true }); + if (process.env[PROXY_CHILD_ENV_VAR] === '1') { + (serialized as any).runtime = { + proxy: { + active: true, + sessionId: process.env[PROXY_SESSION_ID_ENV_VAR], + sessionUuid: process.env[PROXY_SESSION_UUID_ENV_VAR], + }, + }; + } if (agent) { for (const key in serialized.config) { const item = serialized.config[key]; diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts new file mode 100644 index 000000000..4304580fa --- /dev/null +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -0,0 +1,684 @@ +import { define } from 'gunshi'; +import { gracefulExit } from 'exit-hook'; + +import { exec } from '../../lib/exec'; +import { loadVarlockEnvGraph } from '../../lib/load-graph'; +import { startLocalProxyRuntime } from '../../proxy/runtime-proxy'; +import { + cleanupStaleProxySessions, + createProxySessionRecord, + deleteProxySessionRecord, + getProxySessionExportEnv, + listProxySessions, + reserveProxySessionIdentity, + resolveProxySessionForCommand, + updateProxySessionRecord, + type ProxySessionStats, + type ProxySessionRecord, +} from '../../proxy/session-registry'; +import { + PROXY_PARENT_PID_ENV_VAR, +} from '../../proxy/env-vars'; +import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; +import { resetRedactionMap, redactSensitiveConfig } from '../../runtime/env'; +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; +import { CliExitError } from '../helpers/exit-error'; +import { + checkForConfigErrors, + checkForNoEnvFiles, + checkForSchemaErrors, +} from '../helpers/error-checks'; +import { buildProxySchemaFingerprint } from '../helpers/proxy-schema-fingerprint'; + +export const commandSpec = define({ + name: 'proxy', + description: 'Manage proxy sessions for placeholder-based agent workflows', + args: { + session: { + type: 'string', + short: 's', + description: 'Proxy session ID/alias', + }, + all: { + type: 'boolean', + description: 'Target all sessions (supported by stop/status)', + }, + format: { + type: 'string', + short: 'f', + description: 'Output format (used by `proxy env`): shell (default) or json', + }, + watch: { + type: 'boolean', + description: 'Continuously refresh status output (used by `proxy status`).', + }, + interval: { + type: 'string', + description: 'Polling interval in milliseconds for `proxy status --watch` (default: 1000).', + }, + path: { + type: 'string', + short: 'p', + multiple: true, + description: 'Path to a specific .env file or directory to use as the entry point (can be specified multiple times)', + }, + 'no-redact-stdout': { + type: 'boolean', + description: 'Disable stdout/stderr redaction and use stdio inherit for full TTY pass-through', + }, + inject: { + type: 'string', + short: 'i', + description: 'Control what gets injected into child env for `proxy run`: "all" (default), "vars", or "blob"', + }, + }, + examples: ` +Proxy command surface: + varlock proxy run -- claude + varlock proxy start + varlock proxy env --session abc12 + varlock proxy status + varlock proxy refresh --session abc12 + varlock proxy stop --session abc12 + varlock proxy stop --all + `.trim(), +}); + +type PreparedProxyPolicy = { + resolvedEnv: Record; + serializedGraph: any; + schemaFingerprint: string; + proxyManagedItems: Array; + proxyRules: Array; + egressMode: 'permissive' | 'strict'; +}; + +const EMPTY_PROXY_SESSION_STATS: ProxySessionStats = { + totalRequests: 0, + matchedRequests: 0, + blockedRequests: 0, +}; + +function cloneSessionStats(stats?: ProxySessionStats): ProxySessionStats { + return { + totalRequests: stats?.totalRequests ?? 0, + matchedRequests: stats?.matchedRequests ?? 0, + blockedRequests: stats?.blockedRequests ?? 0, + ...(stats?.lastActivityAt ? { lastActivityAt: stats.lastActivityAt } : {}), + }; +} + +function createSessionStatsWriter(sessionUuid: string, initial?: ProxySessionStats) { + const stats = cloneSessionStats(initial); + let flushTimer: ReturnType | undefined; + let stopped = false; + + const flush = async () => { + if (stopped) return; + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = undefined; + } + await updateProxySessionRecord(sessionUuid, { + stats: cloneSessionStats(stats), + }).catch(() => undefined); + }; + + const scheduleFlush = () => { + if (stopped || flushTimer) return; + flushTimer = setTimeout(() => { + flush().catch(() => undefined); + }, 500); + }; + + return { + stats, + onActivity(activity: { + matched: boolean; + blocked: boolean; + }) { + if (stopped) return; + stats.totalRequests += 1; + if (activity.matched) stats.matchedRequests += 1; + if (activity.blocked) stats.blockedRequests += 1; + stats.lastActivityAt = new Date().toISOString(); + scheduleFlush(); + }, + async flushNow() { + await flush(); + }, + stop() { + stopped = true; + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = undefined; + } + }, + }; +} + +export function getBlockedSensitiveKeys( + envGraph: Awaited>, + proxyManagedItems: Array, +): Array { + const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); + const blockedKeys: Array = []; + for (const key of envGraph.sortedConfigKeys) { + const item = envGraph.configSchema[key]; + if (!item?.isSensitive) continue; + if (item.resolvedValue === undefined) continue; + if (managedKeys.has(key)) continue; + if (item.getDec('proxyPassthrough')) continue; + blockedKeys.push(key); + } + return blockedKeys; +} + +function quoteForShell(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function toShellExports(env: Record): string { + return Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `export ${k}=${quoteForShell(v)}`) + .join('\n'); +} + +function getAction(ctx: any): string { + const positionals = (ctx.positionals ?? []).slice(ctx.commandPath?.length ?? 0); + const action = positionals[0]; + if (!action) { + throw new CliExitError( + 'Missing proxy action.', + { suggestion: 'Use one of: run, start, env, status, refresh, stop' }, + ); + } + return action; +} + +function getRunCommandArgs(): Array { + const argv = process.argv.slice(2); + const doubleDashIndex = argv.indexOf('--'); + if (doubleDashIndex === -1) { + throw new CliExitError( + 'No command to run. Use `varlock proxy run -- `.', + ); + } + const rest = argv.slice(doubleDashIndex + 1); + if (!rest.length) { + throw new CliExitError( + 'No command to run. Use `varlock proxy run -- `.', + ); + } + return rest; +} + +async function prepareProxyPolicy(entryFilePaths?: Array): Promise { + const envGraph = await loadVarlockEnvGraph({ + entryFilePaths, + }); + checkForSchemaErrors(envGraph); + checkForNoEnvFiles(envGraph); + + await envGraph.generateTypesIfNeeded(); + await envGraph.resolveEnvValues(); + checkForConfigErrors(envGraph); + + const resolvedEnv = envGraph.getResolvedEnvObject(); + const serializedGraph = envGraph.getSerializedGraph(); + const schemaFingerprint = buildProxySchemaFingerprint(envGraph); + const proxyManagedItems = await envGraph.getProxyManagedItems(); + const proxyRules = await envGraph.getProxyRules(); + const blockedSensitiveKeys = getBlockedSensitiveKeys(envGraph, proxyManagedItems); + if (blockedSensitiveKeys.length) { + throw new CliExitError( + [ + 'Proxy session blocked: found sensitive items without proxy handling.', + `Keys: ${blockedSensitiveKeys.join(', ')}`, + ].join('\n'), + { + suggestion: 'Attach @proxy(...) to manage these values, or add @proxyPassthrough for explicit passthrough.', + }, + ); + } + + for (const managedItem of proxyManagedItems) { + resolvedEnv[managedItem.key] = managedItem.placeholder; + if (serializedGraph.config[managedItem.key]) { + serializedGraph.config[managedItem.key].value = managedItem.placeholder; + } + } + + return { + resolvedEnv, + serializedGraph, + schemaFingerprint, + proxyManagedItems, + proxyRules, + egressMode: serializedGraph.settings?.proxyEgress ?? 'permissive', + }; +} + +async function createRuntimeAndSession(opts: { + policy: PreparedProxyPolicy; + entryPaths?: Array; + command?: Array; +}): Promise<{ + runtime: Awaited>; + session: ProxySessionRecord; + statsWriter: ReturnType; +}> { + const identity = await reserveProxySessionIdentity(); + const statsWriter = createSessionStatsWriter(identity.uuid, EMPTY_PROXY_SESSION_STATS); + const runtime = await startLocalProxyRuntime({ + managedItems: opts.policy.proxyManagedItems, + rules: opts.policy.proxyRules, + egressMode: opts.policy.egressMode, + onActivity: statsWriter.onActivity, + }); + const now = new Date().toISOString(); + const session = await createProxySessionRecord({ + id: identity.id, + uuid: identity.uuid, + ownerPid: process.pid, + cwd: process.cwd(), + startedAt: now, + egressMode: opts.policy.egressMode, + schemaFingerprint: opts.policy.schemaFingerprint, + placeholderOverrides: Object.fromEntries( + opts.policy.proxyManagedItems.map((item) => [item.key, item.placeholder]), + ), + stats: cloneSessionStats(statsWriter.stats), + env: Object.fromEntries( + Object.entries(runtime.env).filter((entry): entry is [string, string] => !!entry[1]), + ), + ...(opts.entryPaths?.length ? { entryPaths: opts.entryPaths } : {}), + ...(opts.command?.length ? { command: opts.command } : {}), + }); + + return { runtime, session, statsWriter }; +} + +function applyInjectModeFromFlags(ctx: any): { + injectVars: boolean; + injectBlob: boolean; +} { + const injectMode = ctx.values.inject ?? 'all'; + const validModes = ['all', 'vars', 'blob']; + if (!validModes.includes(injectMode)) { + throw new CliExitError(`Invalid --inject mode: "${injectMode}". Must be one of: ${validModes.join(', ')}`); + } + + return { + injectVars: injectMode === 'all' || injectMode === 'vars', + injectBlob: injectMode === 'all' || injectMode === 'blob', + }; +} + +function formatSessionStatus(session: ProxySessionRecord): string { + const child = session.childPid ? ` child=${session.childPid}` : ''; + const stats = session.stats ?? EMPTY_PROXY_SESSION_STATS; + const last = stats.lastActivityAt ? ` last=${stats.lastActivityAt}` : ''; + return `[${session.id}] ${session.uuid} pid=${session.ownerPid}${child} egress=${session.egressMode} ` + + `cwd=${session.cwd} req=${stats.totalRequests} matched=${stats.matchedRequests} ` + + `blocked=${stats.blockedRequests}${last}`; +} + +function printSessionStatus(session: ProxySessionRecord) { + console.log(formatSessionStatus(session)); +} + +function parseStatusWatchInterval(ctx: any): number { + const raw = ctx.values.interval ?? '1000'; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 100 || parsed > 60_000) { + throw new CliExitError('Invalid --interval. Use a number between 100 and 60000 (milliseconds).'); + } + return Math.floor(parsed); +} + +async function collectStatusSessions(ctx: any): Promise> { + if (ctx.values.session) { + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: false, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + return [session]; + } + + return await listProxySessions(); +} + +async function runAction(ctx: any) { + const commandToRunAsArgs = getRunCommandArgs(); + const rawCommand = commandToRunAsArgs[0]!; + const commandArgsOnly = commandToRunAsArgs.slice(1); + const commandToRunStr = commandToRunAsArgs.join(' '); + + const policy = await prepareProxyPolicy(ctx.values.path); + const { runtime, session, statsWriter } = await createRuntimeAndSession({ + policy, + entryPaths: ctx.values.path, + command: commandToRunAsArgs, + }); + console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); + + const { injectVars, injectBlob } = applyInjectModeFromFlags(ctx); + const sessionEnv = getProxySessionExportEnv(session); + const fullInjectedEnv: NodeJS.ProcessEnv = { + ...process.env, + ...sessionEnv, + [PROXY_PARENT_PID_ENV_VAR]: String(process.pid), + ...(injectVars ? policy.resolvedEnv : {}), + __VARLOCK_RUN: '1', + ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(policy.serializedGraph) } : {}), + }; + + if (injectBlob && !injectVars && process.env._VARLOCK_ENV_KEY) { + fullInjectedEnv._VARLOCK_ENV_KEY = process.env._VARLOCK_ENV_KEY; + } + + let commandProcess: ReturnType | undefined; + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await statsWriter.flushNow(); + statsWriter.stop(); + await runtime.stop().catch(() => undefined); + await deleteProxySessionRecord(session.uuid).catch(() => undefined); + }; + + const redactLogs = policy.serializedGraph.settings?.redactLogs ?? true; + const noRedactStdout = ctx.values['no-redact-stdout'] ?? false; + + if (redactLogs) { + const redactionGraph = { + ...policy.serializedGraph, + config: { ...policy.serializedGraph.config }, + }; + + for (const managedItem of policy.proxyManagedItems) { + const schemaItem = policy.serializedGraph.config[managedItem.key]; + if (!schemaItem?.isSensitive) continue; + if (!managedItem.realValue || managedItem.realValue === schemaItem.value) continue; + redactionGraph.config[`__PROXY_REAL__${managedItem.key}`] = { + value: managedItem.realValue, + isSensitive: true, + }; + } + + resetRedactionMap(redactionGraph); + } + + const writeRedacted = (stream: NodeJS.WriteStream, chunk: Buffer | string) => { + const str = chunk.toString(); + stream.write(redactLogs ? redactSensitiveConfig(str) : str); + }; + + if (noRedactStdout) { + commandProcess = exec(rawCommand, commandArgsOnly, { + stdio: 'inherit', + env: fullInjectedEnv, + }); + } else { + let redactEnv: NodeJS.ProcessEnv = fullInjectedEnv; + if ( + process.stdout.isTTY + && process.env.NO_COLOR === undefined + && process.env.FORCE_COLOR === undefined + ) { + let forceColorLevel = '1'; + if (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit') { + forceColorLevel = '3'; + } else if (process.env.TERM?.includes('256color') || process.env.TERM_PROGRAM === 'iTerm.app') { + forceColorLevel = '2'; + } + redactEnv = { ...fullInjectedEnv, FORCE_COLOR: forceColorLevel }; + } + + commandProcess = exec(rawCommand, commandArgsOnly, { + stdin: 'inherit', + stdout: 'pipe', + stderr: 'pipe', + env: redactEnv, + }); + commandProcess.stdout?.on('data', (chunk: Buffer | string) => writeRedacted(process.stdout, chunk)); + commandProcess.stderr?.on('data', (chunk: Buffer | string) => writeRedacted(process.stderr, chunk)); + } + + if (commandProcess.pid) { + await updateProxySessionRecord(session.uuid, { childPid: commandProcess.pid }); + } + + process.on('exit', () => { + commandProcess?.kill(9); + }); + + ['SIGTERM', 'SIGINT'].forEach((signal) => { + process.on(signal, () => { + commandProcess?.kill(9); + gracefulExit(1); + }); + }); + + let exitCode = 0; + try { + const result = await commandProcess; + exitCode = result.exitCode; + } catch (error) { + if ((error as any).signal === 'SIGINT' || (error as any).signal === 'SIGKILL') { + exitCode = 1; + } else { + console.log((error as Error).message); + console.log(`command [${commandToRunStr}] failed`); + console.log('try running the same command without varlock'); + console.log('if you get a different result, varlock may be the problem...'); + exitCode = (error as any).exitCode || 1; + } + } finally { + await cleanup(); + const stats = statsWriter.stats; + console.error(`Proxy session ${session.id} summary: req=${stats.totalRequests} matched=${stats.matchedRequests} blocked=${stats.blockedRequests}`); + } + + return gracefulExit(exitCode); +} + +async function startAction(ctx: any) { + const policy = await prepareProxyPolicy(ctx.values.path); + const { runtime, session, statsWriter } = await createRuntimeAndSession({ + policy, + entryPaths: ctx.values.path, + }); + + console.log(`Started proxy session ${session.id} (${session.uuid})`); + console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); + console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); + + let cleanedUp = false; + const cleanup = async () => { + if (cleanedUp) return; + cleanedUp = true; + await statsWriter.flushNow(); + statsWriter.stop(); + await runtime.stop().catch(() => undefined); + await deleteProxySessionRecord(session.uuid).catch(() => undefined); + }; + + await new Promise((resolve) => { + const close = () => { + cleanup().finally(resolve); + }; + + process.on('SIGINT', close); + process.on('SIGTERM', close); + }); +} + +async function envAction(ctx: any) { + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: true, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + + const format = (ctx.values.format ?? 'shell').toLowerCase(); + if (format !== 'shell' && format !== 'json') { + throw new CliExitError('Invalid --format for `proxy env`. Use "shell" or "json".'); + } + + const env = getProxySessionExportEnv(session); + if (format === 'json') { + console.log(JSON.stringify(env, null, 2)); + return; + } + + console.log(toShellExports(env)); +} + +async function statusAction(ctx: any) { + const watch = ctx.values.watch ?? false; + const printSnapshot = async () => { + await cleanupStaleProxySessions(); + const sessions = await collectStatusSessions(ctx); + if (!sessions.length) { + console.log('No active proxy sessions.'); + return false; + } + for (const session of sessions) { + printSessionStatus(session); + } + return true; + }; + + if (!watch) { + await printSnapshot(); + return; + } + + const intervalMs = parseStatusWatchInterval(ctx); + let shouldStop = false; + const stopWatching = () => { + shouldStop = true; + }; + + process.on('SIGINT', stopWatching); + process.on('SIGTERM', stopWatching); + + console.log(`Watching proxy status every ${intervalMs}ms. Press Ctrl+C to stop.`); + while (!shouldStop) { + if (process.stdout.isTTY) { + process.stdout.write('\u001Bc'); + } + console.log(`proxy status @ ${new Date().toISOString()}`); + try { + await printSnapshot(); + } catch (error) { + if (ctx.values.session) { + console.log((error as Error).message); + break; + } + throw error; + } + if (shouldStop) break; + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + + process.off('SIGINT', stopWatching); + process.off('SIGTERM', stopWatching); +} + +async function refreshAction(ctx: any) { + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: true, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + + const refreshPaths = ctx.values.path ?? session.entryPaths; + const policy = await prepareProxyPolicy(refreshPaths); + + await updateProxySessionRecord(session.uuid, { + schemaFingerprint: policy.schemaFingerprint, + egressMode: policy.egressMode, + placeholderOverrides: Object.fromEntries( + policy.proxyManagedItems.map((item) => [item.key, item.placeholder]), + ), + ...(refreshPaths?.length ? { entryPaths: refreshPaths } : {}), + }); + + console.log(`Refreshed proxy session ${session.id}.`); +} + +async function stopAction(ctx: any) { + await cleanupStaleProxySessions(); + + if (ctx.values.all) { + const sessions = await listProxySessions(); + if (!sessions.length) { + console.log('No active proxy sessions.'); + return; + } + + for (const session of sessions) { + try { + process.kill(session.ownerPid, 'SIGTERM'); + console.log(`Sent SIGTERM to proxy session ${session.id} (pid ${session.ownerPid}).`); + } catch { + await deleteProxySessionRecord(session.uuid); + } + } + return; + } + + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: true, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + + try { + process.kill(session.ownerPid, 'SIGTERM'); + console.log(`Sent SIGTERM to proxy session ${session.id} (pid ${session.ownerPid}).`); + } catch { + await deleteProxySessionRecord(session.uuid); + console.log(`Removed stale proxy session ${session.id}.`); + } +} + +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + const action = getAction(ctx); + + switch (action) { + case 'run': + return await runAction(ctx); + case 'start': + return await startAction(ctx); + case 'env': + return await envAction(ctx); + case 'status': + return await statusAction(ctx); + case 'refresh': + return await refreshAction(ctx); + case 'stop': + return await stopAction(ctx); + default: + throw new CliExitError( + `Unknown proxy action "${action}".`, + { suggestion: 'Use one of: run, start, env, status, refresh, stop' }, + ); + } +}; diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index aa0bce6b5..5b18be7b3 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -7,8 +7,6 @@ import { loadVarlockEnvGraph } from '../../lib/load-graph'; import { checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors } from '../helpers/error-checks'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; -import { buildProxySchemaFingerprint } from '../helpers/proxy-schema-fingerprint'; -import { startLocalProxyRuntime } from '../../proxy/runtime-proxy'; export const commandSpec = define({ name: 'run', @@ -38,10 +36,6 @@ export const commandSpec = define({ type: 'boolean', description: 'Pass @internal items through to the child process (by default they are stripped, even when set in the ambient env). Use this for a nested `varlock run` whose own resolution needs the internal value (e.g. a secret-zero token).', }, - proxy: { - type: 'boolean', - description: 'Enable proxy placeholder mode for items managed by @proxy rules', - }, path: { type: 'string', short: 'p', @@ -67,7 +61,6 @@ Examples: varlock run -- sh -c 'echo $MY_VAR' # Use shell expansion for env vars varlock run --inject vars -- sh # Inject only individual vars, no blob varlock run --inject blob -- node app.js # Inject only the blob, no individual vars - varlock run --proxy -- node app.js # Inject placeholders for @proxy-managed items varlock run --path .env.prod -- node app.js # Use a specific .env file varlock run --path ./config/ -- node app.js # Use a specific directory varlock run -p ./envs -p ./overrides -- node app.js # Use multiple directories @@ -169,10 +162,11 @@ function signalChild(signal: NodeJS.Signals | number) { } export const commandFn: TypedGunshiCommandFn = async (ctx) => { - let proxyRuntime: Awaited> | undefined; - // if "--" is present, split the args into our command and the rest, which will be another external command const argv = process.argv.slice(2); + if (argv.includes('--proxy')) { + throw new CliExitError('`varlock run` no longer supports `--proxy`.'); + } let restCommandArgs: Array = []; if (argv.includes('--')) { const doubleDashIndex = argv.indexOf('--'); @@ -215,21 +209,6 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const serializedGraph = envGraph.getSerializedGraph(); const { resetRedactionMap } = await import('../../runtime/env'); const { createRedactedStreamWriter } = await import('../../runtime/lib/redact-stream'); - - const proxySchemaFingerprint = ctx.values.proxy ? buildProxySchemaFingerprint(envGraph) : undefined; - let proxyManagedItems = [] as Array>[number]>; - let proxyRules = [] as Array>[number]>; - - if (ctx.values.proxy) { - proxyManagedItems = await envGraph.getProxyManagedItems(); - proxyRules = await envGraph.getProxyRules(); - for (const managedItem of proxyManagedItems) { - resolvedEnv[managedItem.key] = managedItem.placeholder; - if (serializedGraph.config[managedItem.key]) { - serializedGraph.config[managedItem.key].value = managedItem.placeholder; - } - } - } // console.log(resolvedEnv); // handle deprecated --no-inject-graph flag @@ -245,26 +224,11 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } const injectVars = injectMode === 'all' || injectMode === 'vars'; const injectBlob = injectMode === 'all' || injectMode === 'blob'; - if (ctx.values.proxy) { - proxyRuntime = await startLocalProxyRuntime({ - managedItems: proxyManagedItems, - rules: proxyRules, - egressMode: serializedGraph.settings?.proxyEgress ?? 'permissive', - }); - } const fullInjectedEnv: NodeJS.ProcessEnv = { ...process.env, - ...(proxyRuntime?.env ?? {}), ...(injectVars ? resolvedEnv : {}), __VARLOCK_RUN: '1', // flag for a child process to detect it is running via `varlock run` - ...(ctx.values.proxy - ? { - __VARLOCK_PROXY_CHILD: '1', - __VARLOCK_PROXY_PARENT_PID: String(process.pid), - ...(proxySchemaFingerprint ? { __VARLOCK_PROXY_SCHEMA_FINGERPRINT: proxySchemaFingerprint } : {}), - } - : {}), ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(serializedGraph) } : {}), }; @@ -371,25 +335,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = detached: useProcessGroup, }); } else { - // augment the redaction map with the real (injected-by-proxy) secret values so that - // any real value leaking back through the child's output is still redacted, even - // though the child only ever sees placeholders - const redactionGraph = { - ...serializedGraph, - config: { ...serializedGraph.config }, - }; - if (ctx.values.proxy) { - for (const managedItem of proxyManagedItems) { - const schemaItem = serializedGraph.config[managedItem.key]; - if (!schemaItem?.isSensitive) continue; - if (!managedItem.realValue || managedItem.realValue === schemaItem.value) continue; - redactionGraph.config[`__PROXY_REAL__${managedItem.key}`] = { - value: managedItem.realValue, - isSensitive: true, - }; - } - } - resetRedactionMap(redactionGraph); + resetRedactionMap(serializedGraph); commandProcess = exec(rawCommand, commandArgsOnly, { stdin: 'inherit', @@ -425,7 +371,6 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = if (err.signal === 'SIGINT' && childCommandKilledFromRestart) { // console.log('child command failed due to being killed form restart'); childCommandKilledFromRestart = false; - await proxyRuntime?.stop(); return; } @@ -460,10 +405,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } if (!isWatchEnabled) { - await proxyRuntime?.stop(); return gracefulExit(exitCode); } else { - await proxyRuntime?.stop(); console.log('... watching for changes ...'); } }; diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts new file mode 100644 index 000000000..b9a53ddca --- /dev/null +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest'; +import outdent from 'outdent'; +import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; +import { getBlockedSensitiveKeys } from '../proxy.command.js'; + +async function loadGraph(envFile: string) { + const graph = new EnvGraph(); + const source = new DotEnvFileDataSource('.env.schema', { overrideContents: envFile }); + await graph.setRootDataSource(source); + await graph.finishLoad(); + await graph.resolveEnvValues(); + return graph; +} + +describe('getBlockedSensitiveKeys', () => { + test('blocks unmanaged sensitive keys by default', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + PROXIED_SECRET=secret-proxied + + # @sensitive + UNMANAGED_SECRET=secret-unmanaged + `); + + const managedItems = await graph.getProxyManagedItems(); + expect(getBlockedSensitiveKeys(graph, managedItems)).toEqual(['UNMANAGED_SECRET']); + }); + + test('allows unmanaged sensitive key when @proxyPassthrough is present', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + PROXIED_SECRET=secret-proxied + + # @sensitive + # @proxyPassthrough + UNMANAGED_ALLOWED=secret-allowed + `); + + const managedItems = await graph.getProxyManagedItems(); + expect(getBlockedSensitiveKeys(graph, managedItems)).toEqual([]); + }); +}); diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index 83c568157..15ada6660 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -1,173 +1,44 @@ import { CliExitError } from './exit-error'; -import { loadVarlockEnvGraph } from '../../lib/load-graph'; -import { buildProxySchemaFingerprint } from './proxy-schema-fingerprint'; +import { + PROXY_CHILD_ENV_VAR, +} from '../../proxy/env-vars'; -export const PROXY_CHILD_ENV_VAR = '__VARLOCK_PROXY_CHILD'; -export const PROXY_SCHEMA_FINGERPRINT_ENV_VAR = '__VARLOCK_PROXY_SCHEMA_FINGERPRINT'; +export { + PROXY_CHILD_ENV_VAR, +}; -const COMMANDS_DENIED_IN_PROXY = new Set(['run', 'printenv', 'reveal']); -type LoadOutputFormat = 'pretty' | 'json' | 'env' | 'shell' | 'json-full'; +const COMMANDS_DENIED_IN_PROXY = new Set(['reveal']); export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boolean { return env[PROXY_CHILD_ENV_VAR] === '1'; } -/** - * Parse a subset of load flags needed for proxied-context policy checks. - * We only care about format + agent safety flags. - */ -export function parseLoadSafetyArgs(argsAfterCommand: Array): { - format: LoadOutputFormat; - agent: boolean; - env?: string; - paths?: Array; -} { - let format: LoadOutputFormat = 'pretty'; - let agent = false; - let currentEnvFallback: string | undefined; - const paths: Array = []; - - for (let i = 0; i < argsAfterCommand.length; i++) { - const arg = argsAfterCommand[i]; - - if (arg === '--') break; - - if (arg === '--agent') { - agent = true; - continue; - } - - if (arg === '--env') { - const next = argsAfterCommand[i + 1]; - if (next && !next.startsWith('-')) { - currentEnvFallback = next; - } - continue; - } - - if (arg.startsWith('--env=')) { - currentEnvFallback = arg.slice('--env='.length); - continue; - } - - if (arg === '--path' || arg === '-p') { - const next = argsAfterCommand[i + 1]; - if (next && !next.startsWith('-')) { - paths.push(next); - } - continue; - } - - if (arg.startsWith('--path=')) { - paths.push(arg.slice('--path='.length)); - continue; - } - - if (arg.startsWith('-p=')) { - paths.push(arg.slice(3)); - continue; - } - - if (arg === '--format' || arg === '-f') { - const next = argsAfterCommand[i + 1]; - if (next && !next.startsWith('-')) { - const parsed = next as LoadOutputFormat; - if (parsed === 'pretty' || parsed === 'json' || parsed === 'env' || parsed === 'shell' || parsed === 'json-full') { - format = parsed; - } - } - continue; - } - - if (arg.startsWith('--format=')) { - const inline = arg.slice('--format='.length) as LoadOutputFormat; - if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { - format = inline; - } - continue; - } - - if (arg.startsWith('-f=')) { - const inline = arg.slice(3) as LoadOutputFormat; - if (inline === 'pretty' || inline === 'json' || inline === 'env' || inline === 'shell' || inline === 'json-full') { - format = inline; - } - } - } - - return { - format, - agent, - ...(currentEnvFallback ? { env: currentEnvFallback } : {}), - ...(paths.length ? { paths } : {}), - }; -} - -async function assertLoadAllowedInProxy(argsAfterCommand: Array, env: NodeJS.ProcessEnv) { - const { - format, agent, env: currentEnvFallback, paths, - } = parseLoadSafetyArgs(argsAfterCommand); - - if (format === 'pretty' || format === 'json' || format === 'json-full') { - const expectedFingerprint = env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR]; - if (expectedFingerprint) { - let currentFingerprint: string; - try { - const graph = await loadVarlockEnvGraph({ - ...(currentEnvFallback ? { currentEnvFallback } : {}), - ...(paths ? { entryFilePaths: paths } : {}), - }); - currentFingerprint = buildProxySchemaFingerprint(graph); - } catch { - throw new CliExitError( - 'Command blocked in proxied context: schema could not be verified against approved proxy snapshot.', - { - suggestion: 'Restart `varlock run --proxy` after reviewing schema changes in a trusted context.', - }, - ); - } - - if (currentFingerprint !== expectedFingerprint) { - throw new CliExitError( - 'Command blocked in proxied context: schema changed since proxy start and is awaiting trusted approval.', - { - suggestion: 'Restart `varlock run --proxy` after reviewing and approving schema changes.', - }, - ); - } - } - } - - // pretty stays allowed (default, redacted summaries) - if (format === 'pretty') return; - - // json/json-full can be allowed in explicit agent-safe mode - if ((format === 'json' || format === 'json-full') && agent) return; - - throw new CliExitError( - `Command blocked in proxied context: \`varlock load\` with format \`${format}\` can expose raw values.`, - { - suggestion: 'Use `varlock load` (pretty) or `varlock load --agent --format json` instead.', - }, - ); -} - export async function enforceProxyContextGuards(rawArgs: Array, env: NodeJS.ProcessEnv = process.env) { if (!isProxyChildProcess(env)) return; const command = rawArgs[0]; if (!command || command.startsWith('-')) return; + if (command === 'proxy') { + const action = rawArgs[1]; + if (!action || action.startsWith('-')) return; + if (action === 'run' || action === 'start') { + throw new CliExitError( + `Command blocked in proxied context: \`varlock proxy ${action}\` is disabled to prevent nested proxy execution.`, + { + suggestion: 'Use `varlock proxy env`, `varlock proxy status`, or `varlock proxy refresh` from within proxied sessions.', + }, + ); + } + return; + } + if (COMMANDS_DENIED_IN_PROXY.has(command)) { throw new CliExitError( `Command blocked in proxied context: \`varlock ${command}\` is disabled to prevent secret recovery.`, { - suggestion: 'Run this command outside `varlock run --proxy`, or request explicit user approval in a trusted context.', + suggestion: 'Run this command outside `varlock proxy run -- ...`, or request explicit user approval in a trusted context.', }, ); } - - if (command === 'load') { - await assertLoadAllowedInProxy(rawArgs.slice(1), env); - } } diff --git a/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts index 4f34892de..6f2fefe27 100644 --- a/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts +++ b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts @@ -1,130 +1,49 @@ import { - describe, expect, test, vi, beforeEach, + describe, expect, test, } from 'vitest'; -const { - loadVarlockEnvGraphMock, - buildProxySchemaFingerprintMock, -} = vi.hoisted(() => ({ - loadVarlockEnvGraphMock: vi.fn(), - buildProxySchemaFingerprintMock: vi.fn(), -})); - -vi.mock('../../../lib/load-graph', () => ({ - loadVarlockEnvGraph: loadVarlockEnvGraphMock, -})); - -vi.mock('../proxy-schema-fingerprint', () => ({ - buildProxySchemaFingerprint: buildProxySchemaFingerprintMock, -})); - import { CliExitError } from '../exit-error'; import { enforceProxyContextGuards, - parseLoadSafetyArgs, - PROXY_SCHEMA_FINGERPRINT_ENV_VAR, } from '../proxy-context-guard'; -describe('parseLoadSafetyArgs', () => { - test('defaults to pretty and agent=false', () => { - expect(parseLoadSafetyArgs([])).toEqual({ format: 'pretty', agent: false }); - }); - - test('parses --format json with --agent', () => { - expect(parseLoadSafetyArgs(['--format', 'json', '--agent'])).toEqual({ - format: 'json', - agent: true, - }); - }); - - test('parses inline --format=json-full', () => { - expect(parseLoadSafetyArgs(['--format=json-full'])).toEqual({ - format: 'json-full', - agent: false, - }); - }); - - test('parses short -f shell', () => { - expect(parseLoadSafetyArgs(['-f', 'shell'])).toEqual({ - format: 'shell', - agent: false, - }); - }); - - test('parses --path and --env for schema verification', () => { - expect(parseLoadSafetyArgs(['--path', './config/.env.schema', '--env', 'production'])).toEqual({ - format: 'pretty', - agent: false, - env: 'production', - paths: ['./config/.env.schema'], - }); - }); -}); - describe('enforceProxyContextGuards', () => { const proxiedEnv = { __VARLOCK_PROXY_CHILD: '1' } as NodeJS.ProcessEnv; const normalEnv = {} as NodeJS.ProcessEnv; - beforeEach(() => { - loadVarlockEnvGraphMock.mockReset(); - buildProxySchemaFingerprintMock.mockReset(); - loadVarlockEnvGraphMock.mockResolvedValue({}); - buildProxySchemaFingerprintMock.mockReturnValue('approved-fingerprint'); - }); - test('does nothing outside proxied context', async () => { await expect(enforceProxyContextGuards(['reveal'], normalEnv)).resolves.toBeUndefined(); }); - test('blocks nested run in proxied context', async () => { - await expect(enforceProxyContextGuards(['run', '--', 'echo', 'x'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + test('allows nested run in proxied context', async () => { + await expect(enforceProxyContextGuards(['run', '--', 'echo', 'x'], proxiedEnv)).resolves.toBeUndefined(); }); - test('blocks printenv in proxied context', async () => { - await expect(enforceProxyContextGuards(['printenv', 'API_KEY'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + test('allows printenv in proxied context', async () => { + await expect(enforceProxyContextGuards(['printenv', 'API_KEY'], proxiedEnv)).resolves.toBeUndefined(); }); test('blocks reveal in proxied context', async () => { await expect(enforceProxyContextGuards(['reveal', 'API_KEY'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); }); - test('allows load pretty in proxied context', async () => { + test('allows load commands in proxied context', async () => { await expect(enforceProxyContextGuards(['load'], proxiedEnv)).resolves.toBeUndefined(); await expect(enforceProxyContextGuards(['load', '--format', 'pretty'], proxiedEnv)).resolves.toBeUndefined(); - }); - - test('blocks load json without --agent in proxied context', async () => { - await expect(enforceProxyContextGuards(['load', '--format', 'json'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); - }); - - test('allows load json with --agent in proxied context', async () => { await expect(enforceProxyContextGuards(['load', '--format', 'json', '--agent'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['load', '--format', 'json'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['load', '--format', 'shell'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['load', '--format', 'env'], proxiedEnv)).resolves.toBeUndefined(); }); - test('blocks load shell/env in proxied context', async () => { - await expect(enforceProxyContextGuards(['load', '--format', 'shell'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); - await expect(enforceProxyContextGuards(['load', '--format', 'env'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + test('blocks nested proxy run/start in proxied context', async () => { + await expect(enforceProxyContextGuards(['proxy', 'run', '--', 'claude'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); + await expect(enforceProxyContextGuards(['proxy', 'start'], proxiedEnv)).rejects.toBeInstanceOf(CliExitError); }); - test('blocks load when schema fingerprint no longer matches approved snapshot', async () => { - const envWithFingerprint = { - ...proxiedEnv, - [PROXY_SCHEMA_FINGERPRINT_ENV_VAR]: 'approved-fingerprint', - } as NodeJS.ProcessEnv; - - buildProxySchemaFingerprintMock.mockReturnValue('different-fingerprint'); - - await expect(enforceProxyContextGuards(['load'], envWithFingerprint)).rejects.toBeInstanceOf(CliExitError); - }); - - test('allows load when schema fingerprint matches approved snapshot', async () => { - const envWithFingerprint = { - ...proxiedEnv, - [PROXY_SCHEMA_FINGERPRINT_ENV_VAR]: 'approved-fingerprint', - } as NodeJS.ProcessEnv; - - buildProxySchemaFingerprintMock.mockReturnValue('approved-fingerprint'); - - await expect(enforceProxyContextGuards(['load'], envWithFingerprint)).resolves.toBeUndefined(); + test('allows non-launch proxy commands in proxied context', async () => { + await expect(enforceProxyContextGuards(['proxy', 'status'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['proxy', 'env'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['proxy', 'refresh'], proxiedEnv)).resolves.toBeUndefined(); }); }); diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 932d82087..3b2e00d53 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -609,6 +609,9 @@ export const builtInItemDecorators: Array> = [ } }, }, + { + name: 'proxyPassthrough', + }, // test-only decorators — dropped in release builds ...__VARLOCK_BUILD_TYPE__ === 'test' ? [ diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 2cd863bca..9fa970fc9 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -9,6 +9,7 @@ import { runWithWorkspaceInfo } from './workspace-utils'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { createDebug } from './debug'; import { parseOverrideProvenanceMetadata, selectOverrideValuesFromEnv } from './injected-env-provenance'; +import { getProxyPlaceholderOverridesForEnv } from '../proxy/session-registry'; const debug = createDebug('varlock:load'); @@ -50,6 +51,7 @@ function loadFromPaths( overrideValues?: Record, clearCache?: boolean, skipCache?: boolean, + proxyPlaceholderOverrides?: Record, }, ) { const resolvedPaths = rawPaths.map((p) => path.resolve(p)); @@ -80,11 +82,17 @@ function loadFromPaths( afterInit: async (g) => { g.registerResolver(VarlockResolver); g.registerResolver(KeychainResolver); + if (config.proxyPlaceholderOverrides) { + g.overrideValues = { + ...g.overrideValues, + ...config.proxyPlaceholderOverrides, + }; + } }, }))); } -export function loadVarlockEnvGraph(opts?: { +export async function loadVarlockEnvGraph(opts?: { currentEnvFallback?: string, /** Explicit entry file paths from --path flag(s) - overrides package.json config */ entryFilePaths?: Array, @@ -94,6 +102,11 @@ export function loadVarlockEnvGraph(opts?: { skipCache?: boolean, }) { const runtimeOverrideValues = getGraphEnvOverridesFromRuntimeEnv(); + const proxyPlaceholderOverrides = await getProxyPlaceholderOverridesForEnv().catch(() => undefined); + if (proxyPlaceholderOverrides) { + debug('applying %d proxy placeholder override(s)', Object.keys(proxyPlaceholderOverrides).length); + } + const cliPaths = opts?.entryFilePaths?.filter(Boolean); // If --path flag(s) provided, they take precedence over package.json config @@ -107,6 +120,7 @@ export function loadVarlockEnvGraph(opts?: { overrideValues: runtimeOverrideValues, clearCache: opts?.clearCache, skipCache: opts?.skipCache, + proxyPlaceholderOverrides, }); } @@ -123,6 +137,7 @@ export function loadVarlockEnvGraph(opts?: { overrideValues: runtimeOverrideValues, clearCache: opts?.clearCache, skipCache: opts?.skipCache, + proxyPlaceholderOverrides, }); } @@ -137,6 +152,12 @@ export function loadVarlockEnvGraph(opts?: { afterInit: async (g) => { g.registerResolver(VarlockResolver); g.registerResolver(KeychainResolver); + if (proxyPlaceholderOverrides) { + g.overrideValues = { + ...g.overrideValues, + ...proxyPlaceholderOverrides, + }; + } }, }))); } diff --git a/packages/varlock/src/lib/test/load-graph.test.ts b/packages/varlock/src/lib/test/load-graph.test.ts new file mode 100644 index 000000000..5b9db0f7a --- /dev/null +++ b/packages/varlock/src/lib/test/load-graph.test.ts @@ -0,0 +1,54 @@ +import { + afterEach, beforeEach, describe, expect, test, vi, +} from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const { getProxyPlaceholderOverridesForEnvMock } = vi.hoisted(() => ({ + getProxyPlaceholderOverridesForEnvMock: vi.fn(), +})); + +vi.mock('../../proxy/session-registry', () => ({ + getProxyPlaceholderOverridesForEnv: getProxyPlaceholderOverridesForEnvMock, +})); + +import { loadVarlockEnvGraph } from '../load-graph'; + +describe('loadVarlockEnvGraph', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-load-graph-test-')); + fs.writeFileSync(path.join(tempDir, '.env.schema'), [ + '# @defaultSensitive=false', + '# ---', + 'API_KEY=real-secret', + '', + ].join('\n')); + getProxyPlaceholderOverridesForEnvMock.mockReset(); + getProxyPlaceholderOverridesForEnvMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('uses schema value when no proxy placeholder overrides exist', async () => { + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + expect(graph.getResolvedEnvObject().API_KEY).toBe('real-secret'); + }); + + test('applies proxy placeholder overrides when present', async () => { + getProxyPlaceholderOverridesForEnvMock.mockResolvedValue({ + API_KEY: '<>', + }); + + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + expect(graph.getResolvedEnvObject().API_KEY).toBe('<>'); + }); +}); diff --git a/packages/varlock/src/proxy/env-vars.ts b/packages/varlock/src/proxy/env-vars.ts new file mode 100644 index 000000000..8857cca81 --- /dev/null +++ b/packages/varlock/src/proxy/env-vars.ts @@ -0,0 +1,5 @@ +export const PROXY_CHILD_ENV_VAR = '__VARLOCK_PROXY_CHILD'; +export const PROXY_SESSION_ID_ENV_VAR = '__VARLOCK_PROXY_SESSION_ID'; +export const PROXY_SESSION_UUID_ENV_VAR = '__VARLOCK_PROXY_SESSION_UUID'; +export const PROXY_PARENT_PID_ENV_VAR = '__VARLOCK_PROXY_PARENT_PID'; +export const PROXY_SCHEMA_FINGERPRINT_ENV_VAR = '__VARLOCK_PROXY_SCHEMA_FINGERPRINT'; diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 7c10b30d2..66a7ff3a7 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -6,7 +6,11 @@ import { startLocalProxyRuntime } from './runtime-proxy'; async function requestViaProxy(proxyUrl: string, targetUrl: string) { const proxy = new URL(proxyUrl); - return await new Promise<{ statusCode: number; body: string }>((resolve, reject) => { + return await new Promise<{ + statusCode: number; + body: string; + headers: http.IncomingHttpHeaders; + }>((resolve, reject) => { const req = http.request({ host: proxy.hostname, port: Number(proxy.port), @@ -19,6 +23,7 @@ async function requestViaProxy(proxyUrl: string, targetUrl: string) { resolve({ statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8'), + headers: res.headers, }); }); }); @@ -62,4 +67,57 @@ describe('startLocalProxyRuntime', () => { expect(response.body).toContain('strict mode'); await runtime.stop(); }); + + test('redacts matched response headers and body', async () => { + const secret = 'real-secret-value'; + const placeholder = 'placeholder-value'; + const upstream = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('x-upstream-secret', `token=${secret}`); + res.end(JSON.stringify({ + ok: true, + apiKey: secret, + })); + }); + + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const runtime = await startLocalProxyRuntime({ + managedItems: [ + { + key: 'API_KEY', + placeholder, + realValue: secret, + }, + ], + rules: [ + { + source: 'attached', + domain: ['127.0.0.1'], + itemKeys: ['API_KEY'], + }, + ], + egressMode: 'permissive', + }); + + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(placeholder); + expect(response.body).not.toContain(secret); + expect(String(response.headers['x-upstream-secret'])).toContain(placeholder); + expect(String(response.headers['x-upstream-secret'])).not.toContain(secret); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => { + resolve(); + }); + }); + }); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 8e5c74deb..982205e9a 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -23,10 +23,16 @@ export type StartLocalProxyRuntimeInput = { managedItems: Array; rules: Array; egressMode: ProxyEgressMode; + onActivity?: (activity: { + matched: boolean; + blocked: boolean; + }) => void; }; type HostInfo = { host: string, port: number }; +type HeaderTransformFn = (value: string) => string; + function parseHostPort(value: string): HostInfo | null { const [host, portRaw] = value.split(':'); if (!host) return null; @@ -53,7 +59,7 @@ function hostMatchesProxyRules(host: string, rules: Array): boolean { return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); } -function replaceUsingManagedItems(value: string, managedItems: Array): string { +function replacePlaceholdersWithReal(value: string, managedItems: Array): string { let next = value; for (const item of managedItems) { if (item.placeholder) { @@ -63,22 +69,118 @@ function replaceUsingManagedItems(value: string, managedItems: Array): string { + let next = value; + const sortedByRealLength = [...managedItems] + .filter((item) => !!item.realValue && !!item.placeholder) + .sort((a, b) => b.realValue.length - a.realValue.length); + for (const item of sortedByRealLength) { + next = next.split(item.realValue).join(item.placeholder); + } + return next; +} + function transformHeaders( headers: http.IncomingHttpHeaders, - managedItems: Array, + transformValue: HeaderTransformFn, ): Record> { const out: Record> = {}; for (const [key, val] of Object.entries(headers)) { if (val === undefined) continue; if (Array.isArray(val)) { - out[key] = val.map((v) => replaceUsingManagedItems(v, managedItems)); + out[key] = val.map((v) => transformValue(v)); } else { - out[key] = replaceUsingManagedItems(String(val), managedItems); + out[key] = transformValue(String(val)); } } return out; } +function getHeaderValue( + headers: http.IncomingHttpHeaders, + key: string, +): string | undefined { + const raw = headers[key.toLowerCase()]; + if (raw === undefined) return undefined; + if (Array.isArray(raw)) return raw[0]; + return String(raw); +} + +function isUncompressedResponse(headers: http.IncomingHttpHeaders): boolean { + const contentEncoding = getHeaderValue(headers, 'content-encoding'); + if (!contentEncoding) return true; + const tokens = contentEncoding.split(',').map((token) => token.trim().toLowerCase()).filter(Boolean); + if (!tokens.length) return true; + return tokens.every((token) => token === 'identity'); +} + +function isTextLikeResponse(headers: http.IncomingHttpHeaders): boolean { + const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase(); + if (!contentType) return false; + return contentType.startsWith('text/') + || contentType.includes('json') + || contentType.includes('xml') + || contentType.includes('javascript') + || contentType.includes('x-www-form-urlencoded') + || contentType.includes('graphql'); +} + +function shouldRedactResponseBody(headers: http.IncomingHttpHeaders): boolean { + return isUncompressedResponse(headers) && isTextLikeResponse(headers); +} + +function redactOutgoingHeaders( + headers: http.IncomingHttpHeaders, + managedItems: Array, +): Record> { + return transformHeaders( + headers, + (value) => replaceRealWithPlaceholders(value, managedItems), + ); +} + +function forwardUpstreamResponseWithRedaction( + upstreamRes: http.IncomingMessage, + clientRes: http.ServerResponse, + managedItems: Array, + shouldRedact: boolean, +) { + const statusCode = upstreamRes.statusCode ?? 502; + const outgoingHeaders = shouldRedact + ? redactOutgoingHeaders(upstreamRes.headers, managedItems) + : { ...upstreamRes.headers }; + + if (!shouldRedact || !shouldRedactResponseBody(upstreamRes.headers)) { + clientRes.writeHead(statusCode, outgoingHeaders); + upstreamRes.pipe(clientRes); + return; + } + + const chunks: Array = []; + upstreamRes.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + upstreamRes.on('end', () => { + const originalBody = Buffer.concat(chunks).toString('utf8'); + const redactedBody = replaceRealWithPlaceholders(originalBody, managedItems); + const redactedBuffer = Buffer.from(redactedBody, 'utf8'); + + const headersForWrite = { ...outgoingHeaders }; + headersForWrite['content-length'] = String(redactedBuffer.byteLength); + delete headersForWrite['transfer-encoding']; + delete headersForWrite.etag; + + clientRes.writeHead(statusCode, headersForWrite); + clientRes.end(redactedBuffer); + }); + + upstreamRes.on('error', () => { + if (!clientRes.headersSent) clientRes.statusCode = 502; + clientRes.end('Upstream proxy error'); + }); +} + function sanitizeHostForFilename(host: string): string { return host.replaceAll(/[^a-zA-Z0-9.-]/g, '_'); } @@ -196,17 +298,18 @@ async function readBody(req: http.IncomingMessage): Promise { } function buildPathnameAndQuery(input: string, managedItems: Array): string { - return replaceUsingManagedItems(input, managedItems); + return replacePlaceholdersWithReal(input, managedItems); } /** - * Local MITM proxy runtime for `varlock run --proxy`. + * Local MITM proxy runtime for `varlock proxy run`. * Rewrites placeholder values to real values for requests matching @proxy domains. */ export async function startLocalProxyRuntime({ managedItems, rules, egressMode, + onActivity, }: StartLocalProxyRuntimeInput): Promise { const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); const { caKeyPath, caCertPath, combinedCaPath } = await createProxyCa(certsDir); @@ -223,15 +326,28 @@ export async function startLocalProxyRuntime({ const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { + onActivity?.({ + matched: shouldRewrite, + blocked: true, + }); res.statusCode = 403; res.end('Proxy egress blocked by strict mode'); return; } + onActivity?.({ + matched: shouldRewrite, + blocked: false, + }); const body = await readBody(req); const bodyString = body.toString('utf8'); - const rewrittenBody = shouldRewrite ? replaceUsingManagedItems(bodyString, managedItems) : bodyString; - - const upstreamHeaders = transformHeaders(req.headers, shouldRewrite ? managedItems : []); + const rewrittenBody = shouldRewrite ? replacePlaceholdersWithReal(bodyString, managedItems) : bodyString; + + const upstreamHeaders = transformHeaders( + req.headers, + shouldRewrite + ? (value) => replacePlaceholdersWithReal(value, managedItems) + : (value) => value, + ); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; @@ -251,8 +367,12 @@ export async function startLocalProxyRuntime({ path: rewrittenPath, headers: upstreamHeaders, }, (upstreamRes) => { - res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); - upstreamRes.pipe(res); + forwardUpstreamResponseWithRedaction( + upstreamRes, + res, + managedItems, + shouldRewrite, + ); }); upstreamReq.on('error', () => { @@ -319,21 +439,34 @@ export async function startLocalProxyRuntime({ const shouldRewrite = hostMatchesProxyRules(destination.hostname, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { + onActivity?.({ + matched: shouldRewrite, + blocked: true, + }); clientRes.statusCode = 403; clientRes.end('Proxy egress blocked by strict mode'); return; } + onActivity?.({ + matched: shouldRewrite, + blocked: false, + }); const isHttps = destination.protocol === 'https:'; const body = await readBody(clientReq); const bodyString = body.toString('utf8'); - const rewrittenBody = shouldRewrite ? replaceUsingManagedItems(bodyString, managedItems) : bodyString; + const rewrittenBody = shouldRewrite ? replacePlaceholdersWithReal(bodyString, managedItems) : bodyString; const rewrittenPath = shouldRewrite ? buildPathnameAndQuery(`${destination.pathname}${destination.search}`, managedItems) : `${destination.pathname}${destination.search}`; - const upstreamHeaders = transformHeaders(clientReq.headers, shouldRewrite ? managedItems : []); + const upstreamHeaders = transformHeaders( + clientReq.headers, + shouldRewrite + ? (value) => replacePlaceholdersWithReal(value, managedItems) + : (value) => value, + ); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; upstreamHeaders.host = destination.host; @@ -350,8 +483,12 @@ export async function startLocalProxyRuntime({ path: rewrittenPath, headers: upstreamHeaders, }, (upstreamRes) => { - clientRes.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); - upstreamRes.pipe(clientRes); + forwardUpstreamResponseWithRedaction( + upstreamRes, + clientRes, + managedItems, + shouldRewrite, + ); }); upstream.on('error', () => { @@ -372,6 +509,10 @@ export async function startLocalProxyRuntime({ const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { + onActivity?.({ + matched: shouldRewrite, + blocked: true, + }); clientSocket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 34\r\nConnection: close\r\n\r\nProxy egress blocked by strict mode'); clientSocket.destroy(); return; diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts new file mode 100644 index 000000000..ba0022074 --- /dev/null +++ b/packages/varlock/src/proxy/session-registry.ts @@ -0,0 +1,269 @@ +import crypto from 'node:crypto'; +import { + mkdir, readdir, readFile, rm, writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getUserVarlockDir } from '../lib/user-config-dir'; +import type { ProxyEgressMode } from './types'; +import { + PROXY_CHILD_ENV_VAR, + PROXY_SCHEMA_FINGERPRINT_ENV_VAR, + PROXY_SESSION_ID_ENV_VAR, + PROXY_SESSION_UUID_ENV_VAR, +} from './env-vars'; + +export type ProxySessionRecord = { + id: string; + uuid: string; + ownerPid: number; + childPid?: number; + cwd: string; + startedAt: string; + updatedAt: string; + egressMode: ProxyEgressMode; + schemaFingerprint?: string; + placeholderOverrides?: Record; + stats?: ProxySessionStats; + env: Record; + entryPaths?: Array; + command?: Array; +}; + +export type ProxySessionStats = { + totalRequests: number; + matchedRequests: number; + blockedRequests: number; + lastActivityAt?: string; +}; + +const SESSIONS_DIR = join(getUserVarlockDir(), 'proxy', 'sessions'); + +async function ensureSessionsDir() { + await mkdir(SESSIONS_DIR, { recursive: true, mode: 0o700 }); +} + +function getSessionFilePath(uuid: string): string { + return join(SESSIONS_DIR, `${uuid}.json`); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function randomShortId(length = 5): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let out = ''; + while (out.length < length) { + const byte = crypto.randomBytes(1)[0]!; + out += alphabet[byte % alphabet.length]; + } + return out; +} + +function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | undefined { + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== 'object') return undefined; + if (!parsed.uuid || !parsed.id || !parsed.ownerPid) return undefined; + if (!parsed.cwd || !parsed.startedAt || !parsed.updatedAt) return undefined; + if (!parsed.egressMode || !parsed.env) return undefined; + + return { + id: String(parsed.id), + uuid: String(parsed.uuid), + ownerPid: Number(parsed.ownerPid), + ...(parsed.childPid ? { childPid: Number(parsed.childPid) } : {}), + cwd: String(parsed.cwd), + startedAt: String(parsed.startedAt), + updatedAt: String(parsed.updatedAt), + egressMode: parsed.egressMode as ProxyEgressMode, + ...(parsed.schemaFingerprint ? { schemaFingerprint: String(parsed.schemaFingerprint) } : {}), + ...(parsed.placeholderOverrides && typeof parsed.placeholderOverrides === 'object' + ? { + placeholderOverrides: Object.fromEntries( + Object.entries(parsed.placeholderOverrides as Record).map(([k, v]) => [k, String(v)]), + ), + } + : {}), + ...(parsed.stats && typeof parsed.stats === 'object' + ? { + stats: { + totalRequests: Number((parsed.stats as any).totalRequests ?? 0), + matchedRequests: Number((parsed.stats as any).matchedRequests ?? 0), + blockedRequests: Number((parsed.stats as any).blockedRequests ?? 0), + ...((parsed.stats as any).lastActivityAt + ? { lastActivityAt: String((parsed.stats as any).lastActivityAt) } + : {}), + }, + } + : {}), + env: Object.fromEntries( + Object.entries(parsed.env as Record).map(([k, v]) => [k, String(v)]), + ), + ...(Array.isArray(parsed.entryPaths) + ? { entryPaths: parsed.entryPaths.map((v) => String(v)) } + : {}), + ...(Array.isArray(parsed.command) + ? { command: parsed.command.map((v) => String(v)) } + : {}), + }; + } catch { + rm(filePath, { force: true }).catch(() => undefined); + return undefined; + } +} + +export function isProcessRunning(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const err = error as NodeJS.ErrnoException; + return err.code === 'EPERM'; + } +} + +export async function listProxySessions(opts?: { + cleanupStale?: boolean; +}): Promise> { + await ensureSessionsDir(); + + const files = await readdir(SESSIONS_DIR); + const sessions: Array = []; + + for (const fileName of files) { + if (!fileName.endsWith('.json')) continue; + const filePath = join(SESSIONS_DIR, fileName); + const raw = await readFile(filePath, 'utf8').catch(() => undefined); + if (!raw) continue; + + const parsed = parseSessionRecord(raw, filePath); + if (!parsed) continue; + + const running = isProcessRunning(parsed.ownerPid); + if (!running && opts?.cleanupStale) { + await rm(filePath, { force: true }); + continue; + } + + sessions.push(parsed); + } + + return sessions.sort((a, b) => a.startedAt.localeCompare(b.startedAt)); +} + +export async function cleanupStaleProxySessions() { + await listProxySessions({ cleanupStale: true }); +} + +export async function reserveProxySessionIdentity(): Promise<{ id: string; uuid: string }> { + const sessions = await listProxySessions(); + const usedIds = new Set(sessions.map((s) => s.id)); + let id = randomShortId(5); + while (usedIds.has(id)) id = randomShortId(6); + const uuid = crypto.randomUUID(); + return { id, uuid }; +} + +export async function createProxySessionRecord(session: Omit) { + await ensureSessionsDir(); + const next: ProxySessionRecord = { + ...session, + updatedAt: nowIso(), + }; + await writeFile(getSessionFilePath(next.uuid), JSON.stringify(next, null, 2), { mode: 0o600 }); + return next; +} + +export async function getProxySessionByToken(token: string): Promise { + const sessions = await listProxySessions(); + const direct = sessions.find((s) => s.uuid === token || s.id === token); + return direct; +} + +export async function getProxyPlaceholderOverridesForEnv(env: NodeJS.ProcessEnv = process.env) { + if (env[PROXY_CHILD_ENV_VAR] !== '1') return undefined; + const sessionToken = env[PROXY_SESSION_ID_ENV_VAR] ?? env[PROXY_SESSION_UUID_ENV_VAR]; + if (!sessionToken) return undefined; + const session = await getProxySessionByToken(sessionToken); + if (!session?.placeholderOverrides) return undefined; + return session.placeholderOverrides; +} + +export async function updateProxySessionRecord( + uuid: string, + patch: Partial>, +): Promise { + const existing = await getProxySessionByToken(uuid); + if (!existing) return undefined; + const next: ProxySessionRecord = { + ...existing, + ...patch, + updatedAt: nowIso(), + }; + await writeFile(getSessionFilePath(existing.uuid), JSON.stringify(next, null, 2), { mode: 0o600 }); + return next; +} + +export async function deleteProxySessionRecord(uuid: string) { + await rm(getSessionFilePath(uuid), { force: true }); +} + +export async function resolveProxySessionForCommand(opts?: { + explicitSession?: string; + env?: NodeJS.ProcessEnv; + defaultToSingleActive?: boolean; +}): Promise { + await cleanupStaleProxySessions(); + + const explicit = opts?.explicitSession?.trim(); + if (explicit) { + const byToken = await getProxySessionByToken(explicit); + if (!byToken) { + throw new Error(`Proxy session "${explicit}" not found.`); + } + return byToken; + } + + const envSessionId = opts?.env?.[PROXY_SESSION_ID_ENV_VAR]; + if (envSessionId) { + const byEnvId = await getProxySessionByToken(envSessionId); + if (byEnvId) return byEnvId; + } + + const envSessionUuid = opts?.env?.[PROXY_SESSION_UUID_ENV_VAR]; + if (envSessionUuid) { + const byEnvUuid = await getProxySessionByToken(envSessionUuid); + if (byEnvUuid) return byEnvUuid; + } + + if (opts?.defaultToSingleActive === false) { + throw new Error('No proxy session selected. Pass --session .'); + } + + const sessions = await listProxySessions(); + if (sessions.length === 1) return sessions[0]!; + if (sessions.length === 0) { + throw new Error('No active proxy sessions.'); + } + + const ids = sessions.map((s) => `${s.id}`).join(', '); + throw new Error(`Multiple active proxy sessions (${ids}). Pass --session .`); +} + +export function getProxySessionExportEnv(session: ProxySessionRecord): Record { + const env = { + ...session.env, + [PROXY_CHILD_ENV_VAR]: '1', + [PROXY_SESSION_ID_ENV_VAR]: session.id, + [PROXY_SESSION_UUID_ENV_VAR]: session.uuid, + } as Record; + + if (session.schemaFingerprint) { + env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR] = session.schemaFingerprint; + } + + return env; +} From afae6965198ed62b4fd8ea77fb5a2e60ec4cc94f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 10 Jun 2026 11:37:27 -0700 Subject: [PATCH 04/64] feat(varlock): harden local proxy for agent runs Builds on the phase-1 proxy guardrails (PR #755) with security and usability hardening for local, no-sandbox agent runs: - Per-item domain scoping: an item's secret is injected only on hosts its own @proxy rule matches (was: all managed items on any ruled host). - In-memory ephemeral CA via @peculiar/x509 over WebCrypto (EC P-256); CA + per-host leaf private keys never touch disk, drops the openssl dependency. IP-literal ruled domains now get IP SANs. - Schema-fingerprint enforcement actually compares on nested commands, closing the @sensitive-downgrade re-load. - Process-ancestry context detection: guards + placeholder overrides resolve the session by process tree, not just the env marker, closing the `env -u __VARLOCK_PROXY_CHILD` bypass. - Streaming: SSE/unknown-length responses stream through instead of buffering; body redaction limited to bounded small text bodies. - Placeholder generation: drop @example derivation; data-type generatePlaceholder() is the blessed source; warn on generic fallback. - Revert global Accept-Encoding force (kept header + bounded-body redaction). - Correctness: byte-accurate content-length, strict-mode 403 length. - Tests: per-item scoping, ancestry, cert authority, end-to-end TLS MITM (CONNECT + leaf trust + injection + streaming), placeholder priority. --- .bumpy/proxy-phase1-guardrails.md | 12 +- bun.lock | 38 ++++ packages/varlock/package.json | 2 + .../varlock/src/cli/commands/proxy.command.ts | 17 ++ .../src/cli/helpers/proxy-context-guard.ts | 27 ++- .../cli/helpers/proxy-schema-fingerprint.ts | 55 ++++- .../varlock/src/env-graph/lib/env-graph.ts | 3 +- .../src/env-graph/test/proxy-mode.test.ts | 17 +- packages/varlock/src/lib/load-graph.ts | 101 +++++---- .../varlock/src/proxy/cert-authority.test.ts | 43 ++++ packages/varlock/src/proxy/cert-authority.ts | 122 ++++++++++ packages/varlock/src/proxy/placeholder.ts | 60 +++-- .../src/proxy/process-ancestry.test.ts | 28 +++ .../varlock/src/proxy/process-ancestry.ts | 63 ++++++ packages/varlock/src/proxy/proxy-tls.test.ts | 190 ++++++++++++++++ .../varlock/src/proxy/runtime-proxy.test.ts | 143 +++++++++++- packages/varlock/src/proxy/runtime-proxy.ts | 212 +++++++----------- .../varlock/src/proxy/session-registry.ts | 41 +++- packages/varlock/src/proxy/types.ts | 2 + 19 files changed, 952 insertions(+), 224 deletions(-) create mode 100644 packages/varlock/src/proxy/cert-authority.test.ts create mode 100644 packages/varlock/src/proxy/cert-authority.ts create mode 100644 packages/varlock/src/proxy/process-ancestry.test.ts create mode 100644 packages/varlock/src/proxy/process-ancestry.ts create mode 100644 packages/varlock/src/proxy/proxy-tls.test.ts diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md index 41b1f7205..58c4c2668 100644 --- a/.bumpy/proxy-phase1-guardrails.md +++ b/.bumpy/proxy-phase1-guardrails.md @@ -2,4 +2,14 @@ varlock: patch --- -Add proxy guardrails and strict egress enforcement for run --proxy +Add proxy guardrails and strict egress enforcement for run --proxy. + +Local-MVP hardening: per-item domain scoping (an item's secret is injected only on hosts its own rule matches), response redaction of headers + uncompressed bodies (compressed bodies pass through — most APIs don't reflect credentials, and forcing identity on every request wasn't worth the cost), schema-fingerprint enforcement on nested commands (closes the @sensitive-downgrade re-load), and correctness fixes (byte-accurate content-length, strict-mode 403 length). + +In-memory ephemeral CA: cert generation moved from the `openssl` subprocess to `@peculiar/x509` over WebCrypto (EC P-256). CA and per-host leaf private keys are now generated and held in memory — only the public CA cert is written to disk for child trust. Removes the openssl dependency (portability for the compiled binary) and closes the on-disk private-key exposure. Leaf certs for IP-literal ruled domains now use an IP SAN (clients verify IPs against iPAddress, not dNSName), so IP-based `@proxy` domains work. Validated end-to-end through the compiled binary (real HTTPS MITM) and via an in-process TLS integration test. + +Process-ancestry context detection: nested-command guards and placeholder overrides now detect the proxy session by matching a running session's owner/child PID against the current process's parent chain, not just the inherited `__VARLOCK_PROXY_CHILD` marker. Closes the `env -u __VARLOCK_PROXY_CHILD varlock load` bypass that previously recovered real values; a child would now have to daemonize/reparent to escape the process tree. Marker-absent-but-ancestry-positive is logged as a likely bypass probe. + +Streaming responses: the proxy no longer buffers `text/event-stream` (and other unknown-length) responses for body redaction — they stream through incrementally, so LLM/agent token-by-token responses work. Body redaction now applies only to bounded, small (<2MB content-length) text responses; header redaction still applies to all responses. + +Placeholder generation: the placeholder is functionally load-bearing (a bad one fails an SDK's key-format check at client construction), so generation now favors known-format sources. `@example` derivation was removed (a docs field shouldn't double as a validation-critical placeholder); priority is explicit `@placeholder` → data-type `generatePlaceholder()` → `@type` constraints → generic fallback. Items that land on the generic fallback are flagged and a warning is printed at proxy startup. diff --git a/bun.lock b/bun.lock index b1ec0343e..a295c2c0b 100644 --- a/bun.lock +++ b/bun.lock @@ -449,6 +449,7 @@ "@env-spec/utils": "workspace:*", "@gunshi/plugin-completion": "^0.35.1", "@gunshi/plugin-i18n": "^0.35.1", + "@peculiar/x509": "^1", "@sindresorhus/is": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -464,6 +465,7 @@ "is-unicode-supported": "^2.1.0", "is-wsl": "^3.1.0", "outdent": "catalog:", + "reflect-metadata": "^0.2.2", "semver": "^7.7.4", "tsup": "catalog:", "vitest": "catalog:", @@ -1089,6 +1091,30 @@ "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="], + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-rsa": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + "@peggyjs/from-mem": ["@peggyjs/from-mem@3.1.3", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], @@ -1597,6 +1623,8 @@ "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], @@ -2737,6 +2765,10 @@ "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -2773,6 +2805,8 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -3047,6 +3081,8 @@ "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -3447,6 +3483,8 @@ "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], diff --git a/packages/varlock/package.json b/packages/varlock/package.json index cb1826670..9ceb38fae 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -142,6 +142,7 @@ "@env-spec/utils": "workspace:*", "@gunshi/plugin-completion": "^0.35.1", "@gunshi/plugin-i18n": "^0.35.1", + "@peculiar/x509": "^1", "@sindresorhus/is": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -157,6 +158,7 @@ "is-unicode-supported": "^2.1.0", "is-wsl": "^3.1.0", "outdent": "catalog:", + "reflect-metadata": "^0.2.2", "semver": "^7.7.4", "tsup": "catalog:", "vitest": "catalog:" diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 4304580fa..54e3c0901 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -217,6 +217,9 @@ function getRunCommandArgs(): Array { async function prepareProxyPolicy(entryFilePaths?: Array): Promise { const envGraph = await loadVarlockEnvGraph({ entryFilePaths, + // The proxy command manages the session fingerprint itself; don't subject + // its own loads to the nested-context guard (would block `proxy refresh`). + skipProxyFingerprintGuard: true, }); checkForSchemaErrors(envGraph); checkForNoEnvFiles(envGraph); @@ -230,6 +233,20 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise item.placeholderIsGenericFallback) + .map((item) => item.key); + if (genericPlaceholderKeys.length) { + console.error( + `⚠️ Proxy items using a generic placeholder: ${genericPlaceholderKeys.join(', ')}`, + ); + console.error( + ' A generic placeholder may fail an SDK\'s key-format validation (e.g. an `sk-…` prefix check) ' + + 'at client construction. Add an explicit `@placeholder` or a data type with a known format.', + ); + } + const blockedSensitiveKeys = getBlockedSensitiveKeys(envGraph, proxyManagedItems); if (blockedSensitiveKeys.length) { throw new CliExitError( diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index 15ada6660..af29c6e13 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -1,4 +1,6 @@ import { CliExitError } from './exit-error'; +import { createDebug } from '../../lib/debug'; +import { resolveActiveProxySession } from '../../proxy/session-registry'; import { PROXY_CHILD_ENV_VAR, } from '../../proxy/env-vars'; @@ -7,14 +9,37 @@ export { PROXY_CHILD_ENV_VAR, }; +const debug = createDebug('varlock:proxy-guard'); + const COMMANDS_DENIED_IN_PROXY = new Set(['reveal']); export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boolean { return env[PROXY_CHILD_ENV_VAR] === '1'; } +/** + * Whether the current process is running inside a proxy session. The env marker + * is the fast path; process-ancestry is the authoritative fallback that a child + * can't defeat by clearing the marker. When the marker is absent but ancestry + * says we're proxied, that's a likely bypass probe — logged as a signal. + */ +async function isInProxyContext(env: NodeJS.ProcessEnv): Promise { + if (isProxyChildProcess(env)) return true; + + const session = await resolveActiveProxySession(env); + if (session) { + debug( + 'proxy context detected via process ancestry with the env marker absent ' + + '(possible bypass attempt) — session %s', + session.id, + ); + return true; + } + return false; +} + export async function enforceProxyContextGuards(rawArgs: Array, env: NodeJS.ProcessEnv = process.env) { - if (!isProxyChildProcess(env)) return; + if (!(await isInProxyContext(env))) return; const command = rawArgs[0]; if (!command || command.startsWith('-')) return; diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index d671d8f3a..dff0e5937 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -1,11 +1,24 @@ import { createHash } from 'node:crypto'; import type { EnvGraph } from '../../env-graph'; +import { CliExitError } from './exit-error'; +import { getProxySessionByToken } from '../../proxy/session-registry'; +import { + PROXY_CHILD_ENV_VAR, + PROXY_SCHEMA_FINGERPRINT_ENV_VAR, + PROXY_SESSION_ID_ENV_VAR, + PROXY_SESSION_UUID_ENV_VAR, +} from '../../proxy/env-vars'; /** * Build a deterministic fingerprint for schema-level safety controls. * Phase 1 focuses on preventing sensitivity downgrades from taking effect * mid-proxy-run, so we hash item keys + sensitivity/required/type metadata. + * + * Intentionally location-independent (no basePath): the security-relevant + * invariant is the schema *shape*, and including the base path would cause + * false-positive mismatches when a nested command resolves the schema from a + * different working directory than the session was started in. */ export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { const schemaShape = envGraph.sortedConfigKeys.map((key) => { @@ -18,10 +31,44 @@ export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { }; }); - const input = JSON.stringify({ - basePath: envGraph.basePath ?? null, - schemaShape, - }); + const input = JSON.stringify({ schemaShape }); return createHash('sha256').update(input).digest('hex'); } + +/** + * When a command runs inside an active `varlock proxy run` child, re-derive the + * schema fingerprint and refuse if it no longer matches the one captured when + * the session started. Closes the "edit .env.schema, flip @sensitive off, + * re-load to recover raw values" downgrade — a deliberate schema change must be + * re-approved out-of-band via `varlock proxy refresh`. + * + * No-ops outside a proxied context, and when there's no stored fingerprint to + * compare against (fails open rather than blocking unrelated commands). + */ +export async function enforceProxySchemaFingerprint( + envGraph: EnvGraph, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (env[PROXY_CHILD_ENV_VAR] !== '1') return; + + // Prefer the session record as source of truth; fall back to the + // env-exported fingerprint if the registry can't be read. + const sessionToken = env[PROXY_SESSION_ID_ENV_VAR] ?? env[PROXY_SESSION_UUID_ENV_VAR]; + let expected: string | undefined; + if (sessionToken) { + const session = await getProxySessionByToken(sessionToken).catch(() => undefined); + expected = session?.schemaFingerprint; + } + expected ??= env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR]; + if (!expected) return; + + const actual = buildProxySchemaFingerprint(envGraph); + if (actual === expected) return; + + throw new CliExitError('Schema changed inside an active proxy session.', { + details: 'The resolved .env.schema shape no longer matches the fingerprint captured when the proxy session started. This guards against downgrading @sensitive items mid-session to recover real values.', + suggestion: 'Re-approve the change by running `varlock proxy refresh` from a trusted (non-proxied) context, then retry.', + forceExit: true, + }); +} diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 45f7f8c41..8b6c9af1b 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -958,11 +958,12 @@ export class EnvGraph { } if (!_.isString(item.resolvedValue) || item.resolvedValue.length === 0) continue; - const placeholder = await generateProxyPlaceholderForItem(item, usedPlaceholders); + const { placeholder, isGenericFallback } = await generateProxyPlaceholderForItem(item, usedPlaceholders); managedItems.push({ key, placeholder, realValue: item.resolvedValue, + ...(isGenericFallback ? { placeholderIsGenericFallback: true } : {}), }); } diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 2d986c5f1..92b728945 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -58,24 +58,29 @@ describe('proxy decorators', () => { # @placeholder=sk_test_explicit EXPLICIT_KEY=sk_live_real_explicit - # @proxy(domain="api.example.com") - # @example=ghp_ABCD1234WXYZ - EXAMPLE_KEY=ghp_REAL_SECRET - # @proxy(domain="api.example.com") # @type=string(startsWith=tok_, isLength=12) TYPE_KEY=tok_real_secret + + # @proxy(domain="api.example.com") + NO_HINT_KEY=whatever_real_secret `); const managed = await graph.getProxyManagedItems(); const byKey = Object.fromEntries(managed.map((item) => [item.key, item])); + // Explicit @placeholder wins; @type constraints derive a format-shaped placeholder. expect(byKey.EXPLICIT_KEY?.placeholder).toBe('sk_test_explicit'); - expect(byKey.EXAMPLE_KEY?.placeholder).toBe('ghp_000000000000'); expect(byKey.TYPE_KEY?.placeholder).toBe('tok_00000000'); + expect(byKey.EXPLICIT_KEY?.placeholderIsGenericFallback).toBeFalsy(); + expect(byKey.TYPE_KEY?.placeholderIsGenericFallback).toBeFalsy(); + + // No format hint → generic fallback, flagged so the CLI can warn. + expect(byKey.NO_HINT_KEY?.placeholder).toMatch(/^vlk_placeholder_NO_HINT_KEY_/); + expect(byKey.NO_HINT_KEY?.placeholderIsGenericFallback).toBe(true); expect(byKey.EXPLICIT_KEY?.realValue).toBe('sk_live_real_explicit'); - expect(byKey.EXAMPLE_KEY?.realValue).toBe('ghp_REAL_SECRET'); expect(byKey.TYPE_KEY?.realValue).toBe('tok_real_secret'); + expect(byKey.NO_HINT_KEY?.realValue).toBe('whatever_real_secret'); }); }); diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 9fa970fc9..92bfb7d03 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -10,6 +10,7 @@ import { readVarlockPackageJsonConfig } from './package-json-config'; import { createDebug } from './debug'; import { parseOverrideProvenanceMetadata, selectOverrideValuesFromEnv } from './injected-env-provenance'; import { getProxyPlaceholderOverridesForEnv } from '../proxy/session-registry'; +import { enforceProxySchemaFingerprint } from '../cli/helpers/proxy-schema-fingerprint'; const debug = createDebug('varlock:load'); @@ -100,6 +101,13 @@ export async function loadVarlockEnvGraph(opts?: { clearCache?: boolean, /** Skip cache entirely for this invocation */ skipCache?: boolean, + /** + * Skip the proxy schema-fingerprint guard for this load. Used by the `proxy` + * command itself (it manages the session fingerprint directly), so that + * `proxy refresh` can re-approve a schema change without being blocked by the + * very guard it exists to clear. + */ + skipProxyFingerprintGuard?: boolean, }) { const runtimeOverrideValues = getGraphEnvOverridesFromRuntimeEnv(); const proxyPlaceholderOverrides = await getProxyPlaceholderOverridesForEnv().catch(() => undefined); @@ -109,55 +117,62 @@ export async function loadVarlockEnvGraph(opts?: { const cliPaths = opts?.entryFilePaths?.filter(Boolean); - // If --path flag(s) provided, they take precedence over package.json config - if (cliPaths && cliPaths.length > 0) { - // Return early and ignore pkgLoadPaths - return loadFromPaths(cliPaths, { - source: '--path flag', - errorPrefix: 'The --path value does not exist', - errorSuggestion: 'Use `--path` to specify a valid file or directory.', - currentEnvFallback: opts?.currentEnvFallback, - overrideValues: runtimeOverrideValues, - clearCache: opts?.clearCache, - skipCache: opts?.skipCache, - proxyPlaceholderOverrides, - }); - } + const graph = await (async () => { + // If --path flag(s) provided, they take precedence over package.json config + if (cliPaths && cliPaths.length > 0) { + return loadFromPaths(cliPaths, { + source: '--path flag', + errorPrefix: 'The --path value does not exist', + errorSuggestion: 'Use `--path` to specify a valid file or directory.', + currentEnvFallback: opts?.currentEnvFallback, + overrideValues: runtimeOverrideValues, + clearCache: opts?.clearCache, + skipCache: opts?.skipCache, + proxyPlaceholderOverrides, + }); + } + + // Fall back to package.json varlock.loadPath + const pkgLoadPath = readVarlockPackageJsonConfig()?.loadPath; + const pkgLoadPaths = pkgLoadPath ? normalizePkgLoadPath(pkgLoadPath) : undefined; + + if (pkgLoadPaths) { + return loadFromPaths(pkgLoadPaths, { + source: 'package.json varlock.loadPath', + errorPrefix: 'A path in `varlock.loadPath` configured in package.json does not exist', + errorSuggestion: 'Update `varlock.loadPath` in your package.json to point to valid files or directories.', + currentEnvFallback: opts?.currentEnvFallback, + overrideValues: runtimeOverrideValues, + clearCache: opts?.clearCache, + skipCache: opts?.skipCache, + proxyPlaceholderOverrides, + }); + } - // Fall back to package.json varlock.loadPath - const pkgLoadPath = readVarlockPackageJsonConfig()?.loadPath; - const pkgLoadPaths = pkgLoadPath ? normalizePkgLoadPath(pkgLoadPath) : undefined; + debug('no path configured, using cwd: %s', process.cwd()); - if (pkgLoadPaths) { - return loadFromPaths(pkgLoadPaths, { - source: 'package.json varlock.loadPath', - errorPrefix: 'A path in `varlock.loadPath` configured in package.json does not exist', - errorSuggestion: 'Update `varlock.loadPath` in your package.json to point to valid files or directories.', + return captureUsageAfterLoad(runWithWorkspaceInfo(() => loadEnvGraph({ currentEnvFallback: opts?.currentEnvFallback, overrideValues: runtimeOverrideValues, + processEnvOverride: runtimeOverrideValues, clearCache: opts?.clearCache, skipCache: opts?.skipCache, - proxyPlaceholderOverrides, - }); - } + afterInit: async (g) => { + g.registerResolver(VarlockResolver); + g.registerResolver(KeychainResolver); + if (proxyPlaceholderOverrides) { + g.overrideValues = { + ...g.overrideValues, + ...proxyPlaceholderOverrides, + }; + } + }, + }))); + })(); - debug('no path configured, using cwd: %s', process.cwd()); + if (!opts?.skipProxyFingerprintGuard) { + await enforceProxySchemaFingerprint(graph); + } - return captureUsageAfterLoad(runWithWorkspaceInfo(() => loadEnvGraph({ - currentEnvFallback: opts?.currentEnvFallback, - overrideValues: runtimeOverrideValues, - processEnvOverride: runtimeOverrideValues, - clearCache: opts?.clearCache, - skipCache: opts?.skipCache, - afterInit: async (g) => { - g.registerResolver(VarlockResolver); - g.registerResolver(KeychainResolver); - if (proxyPlaceholderOverrides) { - g.overrideValues = { - ...g.overrideValues, - ...proxyPlaceholderOverrides, - }; - } - }, - }))); + return graph; } diff --git a/packages/varlock/src/proxy/cert-authority.test.ts b/packages/varlock/src/proxy/cert-authority.test.ts new file mode 100644 index 000000000..3ee9f70b7 --- /dev/null +++ b/packages/varlock/src/proxy/cert-authority.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest'; +import tls from 'node:tls'; + +import * as x509 from '@peculiar/x509'; + +import { createEphemeralCa, createHostCert } from './cert-authority'; + +describe('cert-authority (in-memory CA)', () => { + test('mints a leaf that loads into Node TLS and is signed by the CA', async () => { + const ca = await createEphemeralCa(); + const leaf = await createHostCert(ca, 'api.example.com'); + + // The PEM material is accepted by Node's TLS stack (this is what + // https.createServer consumes when MITM-ing a host). + expect(() => tls.createSecureContext({ key: leaf.keyPem, cert: leaf.certPem })).not.toThrow(); + + // The leaf is cryptographically signed by the CA. + const leafCert = new x509.X509Certificate(leaf.certPem); + await expect(leafCert.verify({ publicKey: ca.cert.publicKey })).resolves.toBe(true); + + // Subject is the requested host. + expect(leafCert.subject).toContain('api.example.com'); + }); + + test('emits PEM material and keeps no private key in the public CA cert', async () => { + const ca = await createEphemeralCa(); + const leaf = await createHostCert(ca, 'api.example.com'); + + expect(ca.certPem).toContain('BEGIN CERTIFICATE'); + expect(ca.certPem).not.toContain('PRIVATE KEY'); + expect(leaf.certPem).toContain('BEGIN CERTIFICATE'); + expect(leaf.keyPem).toContain('BEGIN PRIVATE KEY'); + }); + + test('a leaf does not verify against an unrelated CA', async () => { + const ca = await createEphemeralCa(); + const otherCa = await createEphemeralCa(); + const leaf = await createHostCert(ca, 'api.example.com'); + + const leafCert = new x509.X509Certificate(leaf.certPem); + await expect(leafCert.verify({ publicKey: otherCa.cert.publicKey })).resolves.toBe(false); + }); +}); diff --git a/packages/varlock/src/proxy/cert-authority.ts b/packages/varlock/src/proxy/cert-authority.ts new file mode 100644 index 000000000..827bffecb --- /dev/null +++ b/packages/varlock/src/proxy/cert-authority.ts @@ -0,0 +1,122 @@ +import { webcrypto } from 'node:crypto'; +import { isIP } from 'node:net'; + +import type * as X509 from '@peculiar/x509'; + +/** + * In-memory ephemeral certificate authority for the MITM proxy. + * + * Uses @peculiar/x509 over the platform WebCrypto (Node/Bun native) rather than + * shelling out to `openssl`. Two wins over the old approach: + * - Private keys (CA + per-host leaf) never touch disk — only the public CA + * cert is written out, for child trust. Closes the "same-user reads the key + * from /tmp" exposure, especially after a crash. + * - No `openssl` subprocess dependency, which matters for the `bun --compile` + * binary where a compatible openssl can't be assumed on the target machine. + * + * EC P-256 is used throughout: leaf certs are minted per-host on first CONNECT + * (in the request hot path), and EC keygen is ~milliseconds vs RSA-2048's tens + * to hundreds. API clients (Node, python, curl, git) all accept ECDSA P-256. + */ + +// Prefer the global WebCrypto (present in Bun and Node 18+); fall back to the +// node:crypto webcrypto export for older Node. +const cryptoApi: Crypto = (globalThis.crypto as Crypto | undefined) ?? (webcrypto as unknown as Crypto); + +const KEY_ALG = { name: 'ECDSA', namedCurve: 'P-256' } as const; +const SIGNING_ALG = { name: 'ECDSA', hash: 'SHA-256' } as const; +const VALIDITY_DAYS = 3; + +// @peculiar/x509's bundled build uses tsyringe, which requires the +// reflect-metadata polyfill to be present before it initializes. Static import +// order isn't preserved through bundling (the binary reorders module init), so +// we load both lazily via dynamic import and await reflect-metadata first — the +// await sequence guarantees the polyfill is applied before x509 initializes. +let x509Promise: Promise | undefined; +async function loadX509(): Promise { + x509Promise ||= (async () => { + await import('reflect-metadata'); + const mod = await import('@peculiar/x509'); + mod.cryptoProvider.set(cryptoApi); + return mod; + })(); + return x509Promise; +} + +export type EphemeralCa = { + privateKey: CryptoKey; + cert: X509.X509Certificate; + certPem: string; +}; + +export type MintedHostCert = { + keyPem: string; + certPem: string; +}; + +function validityWindow(): { notBefore: Date; notAfter: Date } { + const notBefore = new Date(); + const notAfter = new Date(notBefore.getTime() + VALIDITY_DAYS * 24 * 60 * 60 * 1000); + return { notBefore, notAfter }; +} + +async function generateKeyPair(): Promise { + return cryptoApi.subtle.generateKey(KEY_ALG, true, ['sign', 'verify']) as Promise; +} + +async function exportPrivateKeyPem(key: CryptoKey): Promise { + const pkcs8 = await cryptoApi.subtle.exportKey('pkcs8', key); + const b64 = Buffer.from(pkcs8).toString('base64'); + const wrapped = b64.match(/.{1,64}/g)?.join('\n') ?? b64; + return `-----BEGIN PRIVATE KEY-----\n${wrapped}\n-----END PRIVATE KEY-----\n`; +} + +/** Generate a fresh in-memory CA. Private key stays in memory; only the cert is exported. */ +export async function createEphemeralCa(): Promise { + const x509 = await loadX509(); + const keys = await generateKeyPair(); + const { notBefore, notAfter } = validityWindow(); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: 'CN=varlock-proxy-ca', + notBefore, + notAfter, + keys, + signingAlgorithm: SIGNING_ALG, + extensions: [ + new x509.BasicConstraintsExtension(true, undefined, true), + // eslint-disable-next-line no-bitwise -- combining KeyUsage flags is the intended bitmask API + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + ], + }); + + return { privateKey: keys.privateKey, cert, certPem: cert.toString('pem') }; +} + +/** Mint a leaf cert for a host, signed by the CA. Both keys stay in memory. */ +export async function createHostCert(ca: EphemeralCa, host: string): Promise { + const x509 = await loadX509(); + const keys = await generateKeyPair(); + const { notBefore, notAfter } = validityWindow(); + + const cert = await x509.X509CertificateGenerator.create({ + subject: `CN=${host}`, + issuer: ca.cert.subject, + notBefore, + notAfter, + signingAlgorithm: SIGNING_ALG, + publicKey: keys.publicKey, + signingKey: ca.privateKey, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, true), + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth], false), + // IP-literal hosts need an IP SAN (clients verify IPs against iPAddress, + // not dNSName); hostnames use a DNS SAN. + new x509.SubjectAlternativeNameExtension([{ type: isIP(host) ? 'ip' : 'dns', value: host }]), + ], + }); + + const keyPem = await exportPrivateKeyPem(keys.privateKey); + return { keyPem, certPem: cert.toString('pem') }; +} diff --git a/packages/varlock/src/proxy/placeholder.ts b/packages/varlock/src/proxy/placeholder.ts index 5ff6405e2..7a890f413 100644 --- a/packages/varlock/src/proxy/placeholder.ts +++ b/packages/varlock/src/proxy/placeholder.ts @@ -6,24 +6,6 @@ function shortHash(input: string) { return createHash('sha256').update(input).digest('hex').slice(0, 8); } -function fromExampleTemplate(example: string): string { - const lastUnderscoreIdx = example.lastIndexOf('_'); - if (lastUnderscoreIdx >= 0) { - const prefix = example.slice(0, lastUnderscoreIdx + 1); - const suffix = example.slice(lastUnderscoreIdx + 1).replace(/[A-Za-z0-9]/g, '0'); - return `${prefix}${suffix}`; - } - - const firstDigitIdx = example.search(/\d/); - if (firstDigitIdx > 0) { - const prefix = example.slice(0, firstDigitIdx); - const suffix = example.slice(firstDigitIdx).replace(/[A-Za-z0-9]/g, '0'); - return `${prefix}${suffix}`; - } - - return example.replace(/[A-Za-z0-9]/g, '0'); -} - function fromTypeSettings(item: ConfigItem): string | undefined { const typeDecParsedValue = item.getDec('type')?.parsedDecorator.value; if (!typeDecParsedValue || !('simplifiedArgs' in typeDecParsedValue)) return undefined; @@ -70,35 +52,51 @@ function ensureUnique(placeholder: string, usedPlaceholders: Set) { } } +export type GeneratedProxyPlaceholder = { + placeholder: string; + /** + * True when we fell back to the generic `vlk_placeholder_*` form because the + * item gave us no format hint. That placeholder will fail any SDK that + * validates key shape (e.g. an `sk-…` prefix check) at client construction, + * so callers should warn the author to add an explicit `@placeholder` or a + * data type that knows the real format. + */ + isGenericFallback: boolean; +}; + +/** + * Derive a proxy placeholder for an item, in priority order: + * 1. explicit `@placeholder` — the author's exact value + * 2. data-type `generatePlaceholder()` — the blessed source: it encodes the + * provider's real format, so it's the one most likely to pass SDK validation + * 3. `@type` startsWith/isLength constraints — deterministic, but only honors + * the declared validation rules, which may not match the SDK's real checks + * 4. generic fallback — guaranteed-unique but format-agnostic (flagged) + * + * Deriving from `@example` was intentionally removed: a documentation field + * shouldn't double as a functional, validation-critical placeholder. + */ export async function generateProxyPlaceholderForItem( item: ConfigItem, usedPlaceholders: Set, -): Promise { +): Promise { const placeholderDec = item.getDec('placeholder'); if (placeholderDec) { const explicitPlaceholder = await placeholderDec.resolve(); if (_.isString(explicitPlaceholder) && explicitPlaceholder.length > 0) { - return ensureUnique(explicitPlaceholder, usedPlaceholders); + return { placeholder: ensureUnique(explicitPlaceholder, usedPlaceholders), isGenericFallback: false }; } } const generatedByType = item.dataType?.generatePlaceholder(item.resolvedValue); if (_.isString(generatedByType) && generatedByType.length > 0) { - return ensureUnique(generatedByType, usedPlaceholders); - } - - const exampleDec = item.getDec('example'); - if (exampleDec) { - const exampleValue = await exampleDec.resolve(); - if (_.isString(exampleValue) && exampleValue.length > 0) { - return ensureUnique(fromExampleTemplate(exampleValue), usedPlaceholders); - } + return { placeholder: ensureUnique(generatedByType, usedPlaceholders), isGenericFallback: false }; } const fromType = fromTypeSettings(item); if (fromType) { - return ensureUnique(fromType, usedPlaceholders); + return { placeholder: ensureUnique(fromType, usedPlaceholders), isGenericFallback: false }; } - return ensureUnique(buildFallbackPlaceholder(item.key), usedPlaceholders); + return { placeholder: ensureUnique(buildFallbackPlaceholder(item.key), usedPlaceholders), isGenericFallback: true }; } diff --git a/packages/varlock/src/proxy/process-ancestry.test.ts b/packages/varlock/src/proxy/process-ancestry.test.ts new file mode 100644 index 000000000..a5f3619e7 --- /dev/null +++ b/packages/varlock/src/proxy/process-ancestry.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; + +import { getAncestorPids } from './process-ancestry'; + +describe('getAncestorPids', () => { + test('returns this process\'s ancestor chain including its direct parent', () => { + const ancestors = getAncestorPids(); + expect(Array.isArray(ancestors)).toBe(true); + expect(ancestors.length).toBeGreaterThan(0); + // The first ancestor is our direct parent. + expect(ancestors[0]).toBe(process.ppid); + // Self is never included. + expect(ancestors).not.toContain(process.pid); + }); + + test('walking from a given pid excludes that pid', () => { + const ancestors = getAncestorPids(process.pid); + expect(ancestors).not.toContain(process.pid); + // The chain terminates (does not loop) — all entries are unique. + expect(new Set(ancestors).size).toBe(ancestors.length); + }); + + test('returns empty for an implausible pid', () => { + // PID 1 (init) has no real parent in our walk (parent is 0/itself). + const ancestors = getAncestorPids(1); + expect(ancestors).not.toContain(1); + }); +}); diff --git a/packages/varlock/src/proxy/process-ancestry.ts b/packages/varlock/src/proxy/process-ancestry.ts new file mode 100644 index 000000000..a966659ce --- /dev/null +++ b/packages/varlock/src/proxy/process-ancestry.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +/** + * Walk a process's parent-PID chain. Used to detect whether the current process + * is running inside a `varlock proxy run` session's process tree — a signal a + * child can't forge by clearing an inherited env var (it would have to + * daemonize/reparent to escape, a much higher bar). + * + * Linux reads /proc//stat per level (sub-ms). Other POSIX platforms take a + * single `ps` snapshot of all pid/ppid pairs and walk it in memory, so depth + * costs one subprocess regardless of how deep the chain is. + */ + +function getParentPidViaProc(pid: number): number | undefined { + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + // The comm field (2nd) is wrapped in parens and may contain spaces/parens, + // so parse after the final ')'. Remaining fields: state ppid pgrp ... + const after = stat.slice(stat.lastIndexOf(')') + 2); + const fields = after.split(' '); + const ppid = Number(fields[1]); + return Number.isFinite(ppid) ? ppid : undefined; + } catch { + return undefined; + } +} + +function buildPpidMapViaPs(): Map { + const map = new Map(); + try { + const out = execFileSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8' }); + for (const line of out.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) continue; + const pid = Number(parts[0]); + const ppid = Number(parts[1]); + if (Number.isFinite(pid) && Number.isFinite(ppid)) map.set(pid, ppid); + } + } catch { + // ps unavailable — return what we have (empty); callers degrade gracefully. + } + return map; +} + +/** Returns the ancestor PIDs of `startPid` (nearest first), excluding itself. */ +export function getAncestorPids(startPid: number = process.pid, maxDepth = 40): Array { + const ancestors: Array = []; + const isLinux = process.platform === 'linux'; + const ppidMap = isLinux ? undefined : buildPpidMapViaPs(); + + let current = startPid; + const seen = new Set([startPid]); + for (let depth = 0; depth < maxDepth; depth += 1) { + const parent = isLinux ? getParentPidViaProc(current) : ppidMap!.get(current); + if (parent === undefined || parent <= 0 || seen.has(parent)) break; + ancestors.push(parent); + seen.add(parent); + if (parent === 1) break; + current = parent; + } + return ancestors; +} diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts new file mode 100644 index 000000000..a31f8a128 --- /dev/null +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -0,0 +1,190 @@ +import { + afterAll, beforeAll, describe, expect, test, +} from 'vitest'; +import { readFileSync } from 'node:fs'; +import https from 'node:https'; +import net from 'node:net'; +import tls from 'node:tls'; +import { URL } from 'node:url'; + +import { startLocalProxyRuntime } from './runtime-proxy'; +import { createEphemeralCa, createHostCert, type EphemeralCa } from './cert-authority'; + +// End-to-end exercise of the HTTPS MITM path: a real TLS client, trusting only +// the proxy's CA, opens a CONNECT tunnel and handshakes against the proxy's +// minted leaf; the proxy injects the real secret and forwards to a stub HTTPS +// upstream. Covers the cert-trust + CONNECT + injection + streaming mechanics +// that the plain-HTTP unit tests can't reach. + +const UPSTREAM_HOST = '127.0.0.1'; +let upstreamCa: EphemeralCa; +let upstreamCertPem: string; +let upstreamKeyPem: string; +let restoreGlobalCa: () => void; + +beforeAll(async () => { + // Stub upstream's own CA + leaf (IP SAN, since we connect by 127.0.0.1). + upstreamCa = await createEphemeralCa(); + const leaf = await createHostCert(upstreamCa, UPSTREAM_HOST); + upstreamCertPem = leaf.certPem; + upstreamKeyPem = leaf.keyPem; + + // Make the proxy's outbound https.request trust the stub upstream. The proxy + // uses the global agent, so inject the upstream CA there (alongside the real + // roots) and restore afterwards. + const previousCa = https.globalAgent.options.ca; + https.globalAgent.options.ca = [...tls.rootCertificates, upstreamCa.certPem]; + restoreGlobalCa = () => { + https.globalAgent.options.ca = previousCa; + }; +}); + +afterAll(() => { + restoreGlobalCa?.(); +}); + +function startUpstream(handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void) { + const server = https.createServer({ key: upstreamKeyPem, cert: upstreamCertPem }, handler); + return new Promise<{ port: number; close: () => Promise }>((resolve) => { + server.listen(0, UPSTREAM_HOST, () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('no upstream addr'); + resolve({ + port: addr.port, + close: () => new Promise((r) => { + server.close(() => r()); + }), + }); + }); + }); +} + +// Open a CONNECT tunnel through the proxy and TLS-handshake against the proxy's +// minted leaf, trusting only the proxy CA. Resolving at all proves CA trust. +async function openMitmTunnel( + proxyUrl: string, + proxyCaPem: string, + targetPort: number, +): Promise { + const proxy = new URL(proxyUrl); + const rawSocket = net.connect(Number(proxy.port), proxy.hostname); + await new Promise((resolve, reject) => { + rawSocket.once('error', reject); + rawSocket.once('connect', () => resolve()); + }); + await new Promise((resolve, reject) => { + rawSocket.once('data', (chunk: Buffer) => { + const statusLine = chunk.toString('utf8').split('\r\n')[0] ?? ''; + if (/^HTTP\/1\.\d 200/.test(statusLine)) resolve(); + else reject(new Error(`CONNECT failed: ${statusLine}`)); + }); + rawSocket.write(`CONNECT ${UPSTREAM_HOST}:${targetPort} HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${targetPort}\r\n\r\n`); + }); + + const tlsSocket = tls.connect({ socket: rawSocket, host: UPSTREAM_HOST, ca: [proxyCaPem] }); + await new Promise((resolve, reject) => { + tlsSocket.once('error', reject); + tlsSocket.once('secureConnect', () => { + if (tlsSocket.authorized) resolve(); + else reject(tlsSocket.authorizationError ?? new Error('client did not authorize proxy leaf')); + }); + }); + return tlsSocket; +} + +describe('proxy HTTPS MITM (end-to-end)', () => { + test('client trusts the minted leaf and the real key is injected upstream', async () => { + let upstreamAuthHeader = ''; + const upstream = await startUpstream((req, res) => { + upstreamAuthHeader = String(req.headers.authorization ?? ''); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], + rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + const body = await new Promise((resolve, reject) => { + let buf = ''; + let idleTimer: ReturnType; + tlsSocket.on('data', (c: Buffer) => { + buf += c.toString('utf8'); + // The MITM connection may stay keep-alive, so resolve once the response + // has settled rather than waiting for the socket to close. + clearTimeout(idleTimer); + idleTimer = setTimeout(() => resolve(buf), 250); + }); + tlsSocket.on('end', () => resolve(buf)); + tlsSocket.on('error', reject); + tlsSocket.write( + `GET / HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\n` + + 'Authorization: Bearer sk-stub-PLACEHOLDER\r\n\r\n', + ); + }); + + // Client completed the TLS handshake against our leaf (openMitmTunnel would + // have thrown otherwise) and got a 200 back. + expect(body.split('\r\n')[0]).toContain('200'); + // The proxy swapped the placeholder for the real key before the upstream saw it. + expect(upstreamAuthHeader).toBe('Bearer sk-stub-REALKEY'); + expect(upstreamAuthHeader).not.toContain('PLACEHOLDER'); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); + + test('SSE responses stream through the MITM path incrementally', async () => { + const INTER_CHUNK_DELAY = 200; + const upstream = await startUpstream((req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'text/event-stream'); + res.setHeader('cache-control', 'no-cache'); + res.write('data: one\n\n'); + setTimeout(() => { + res.write('data: two\n\n'); + res.end(); + }, INTER_CHUNK_DELAY); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], + rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + const marks = await new Promise>((resolve, reject) => { + const times: Record = {}; + let buf = ''; + tlsSocket.on('data', (c: Buffer) => { + buf += c.toString('utf8'); + for (const marker of ['data: one', 'data: two']) { + if (!(marker in times) && buf.includes(marker)) times[marker] = Date.now(); + } + // Resolve as soon as both events have arrived (connection may stay open). + if ('data: one' in times && 'data: two' in times) resolve(times); + }); + tlsSocket.on('end', () => resolve(times)); + tlsSocket.on('error', reject); + tlsSocket.write(`GET /stream HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\nAuthorization: Bearer sk-stub-PLACEHOLDER\r\n\r\n`); + }); + + expect(marks['data: one']).toBeDefined(); + expect(marks['data: two']).toBeDefined(); + // First event arrived well before the second — proof the MITM path forwarded + // chunks as they came rather than buffering the whole stream. + expect(marks['data: two']! - marks['data: one']!).toBeGreaterThanOrEqual(INTER_CHUNK_DELAY - 80); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); +}); diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 66a7ff3a7..79cc30e20 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -4,7 +4,7 @@ import { URL } from 'node:url'; import { startLocalProxyRuntime } from './runtime-proxy'; -async function requestViaProxy(proxyUrl: string, targetUrl: string) { +async function requestViaProxy(proxyUrl: string, targetUrl: string, headers?: Record) { const proxy = new URL(proxyUrl); return await new Promise<{ statusCode: number; @@ -16,6 +16,7 @@ async function requestViaProxy(proxyUrl: string, targetUrl: string) { port: Number(proxy.port), method: 'GET', path: targetUrl, + headers, }, (res) => { const chunks: Array = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -120,4 +121,144 @@ describe('startLocalProxyRuntime', () => { }); }); }); + + test('only injects an item on hosts its own rule matches (per-item domain scoping)', async () => { + // Capture exactly what the upstream receives, so we can see what the proxy + // forwarded (the response gets re-redacted, so it can't be observed there). + let receivedXTest = ''; + const upstream = http.createServer((req, res) => { + receivedXTest = String(req.headers['x-test'] ?? ''); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const runtime = await startLocalProxyRuntime({ + managedItems: [ + { key: 'ITEM_A', placeholder: 'PH_A_xxxxx', realValue: 'REAL_A_secret' }, + { key: 'ITEM_B', placeholder: 'PH_B_xxxxx', realValue: 'REAL_B_secret' }, + ], + rules: [ + // ITEM_A is scoped to the request host; ITEM_B to a different host. + { source: 'attached', domain: ['127.0.0.1'], itemKeys: ['ITEM_A'] }, + { source: 'attached', domain: ['other-host.example'], itemKeys: ['ITEM_B'] }, + ], + egressMode: 'permissive', + }); + + await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`, { + 'x-test': 'a=PH_A_xxxxx;b=PH_B_xxxxx', + }); + + // ITEM_A's rule matches this host → its placeholder is swapped for the real value. + expect(receivedXTest).toContain('REAL_A_secret'); + // ITEM_B's rule is for a different host → its placeholder must pass through + // untouched, never substituted with the real value (no cross-credential leak). + expect(receivedXTest).toContain('PH_B_xxxxx'); + expect(receivedXTest).not.toContain('REAL_B_secret'); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + + test('passes the client Accept-Encoding through unchanged', async () => { + let receivedAcceptEncoding = ''; + const upstream = http.createServer((req, res) => { + receivedAcceptEncoding = String(req.headers['accept-encoding'] ?? ''); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'PH', realValue: 'secret' }], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + + await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`, { + 'accept-encoding': 'gzip, br, deflate', + }); + + // The proxy no longer forces identity (avoids the bandwidth/compat cost for + // a low-value protection); the client's encoding preference is preserved. + expect(receivedAcceptEncoding).toBe('gzip, br, deflate'); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + + test('streams text/event-stream responses through incrementally (no buffering)', async () => { + const INTER_CHUNK_DELAY = 200; + const upstream = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'text/event-stream'); + res.setHeader('cache-control', 'no-cache'); + res.write('data: one\n\n'); + setTimeout(() => { + res.write('data: two\n\n'); + res.end(); + }, INTER_CHUNK_DELAY); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'PH', realValue: 'secret' }], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + + const proxy = new URL(runtime.env.HTTP_PROXY!); + const { gapMs, body } = await new Promise<{ gapMs: number; body: string }>((resolve, reject) => { + const req = http.request({ + host: proxy.hostname, + port: Number(proxy.port), + method: 'GET', + path: `http://127.0.0.1:${addr.port}/`, + }, (res) => { + let firstAt = 0; + let lastAt = 0; + const chunks: Array = []; + res.on('data', (chunk: Buffer) => { + const now = Date.now(); + firstAt ||= now; + lastAt = now; + chunks.push(chunk); + }); + res.on('end', () => resolve({ gapMs: lastAt - firstAt, body: Buffer.concat(chunks).toString('utf8') })); + }); + req.on('error', reject); + req.end(); + }); + + expect(body).toContain('data: one'); + expect(body).toContain('data: two'); + // If the proxy had buffered the whole response, both chunks would arrive + // together at the end and the gap would be ~0. A gap near the server's + // inter-chunk delay proves chunks were forwarded as they arrived. + expect(gapMs).toBeGreaterThanOrEqual(INTER_CHUNK_DELAY - 80); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 982205e9a..5133641ae 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -1,6 +1,5 @@ -import { execFileSync } from 'node:child_process'; import { - mkdtemp, readFile, rm, writeFile, + mkdtemp, rm, writeFile, } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; @@ -10,6 +9,7 @@ import path from 'node:path'; import tls from 'node:tls'; import { URL } from 'node:url'; +import { createEphemeralCa, createHostCert } from './cert-authority'; import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from './types'; const LOCALHOST = '127.0.0.1'; @@ -59,6 +59,29 @@ function hostMatchesProxyRules(host: string, rules: Array): boolean { return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); } +/** + * Resolve the managed items that are in scope for a given host: only items + * referenced by a rule whose domain matches this host. This enforces per-item + * domain scoping — an item's secret is injected (and its real value redacted on + * the way back) only on the hosts its own rule applies to, never on every ruled + * host. Prevents a child from harvesting item B's real value by reflecting B's + * placeholder into a request to item A's host. + */ +function getHostScopedManagedItems( + host: string, + rules: Array, + managedItems: Array, +): Array { + const allowedKeys = new Set(); + for (const rule of rules) { + if (rule.domain.some((d) => domainMatches(d, host))) { + for (const key of rule.itemKeys) allowedKeys.add(key); + } + } + if (allowedKeys.size === 0) return []; + return managedItems.filter((item) => allowedKeys.has(item.key)); +} + function replacePlaceholdersWithReal(value: string, managedItems: Array): string { let next = value; for (const item of managedItems) { @@ -125,8 +148,30 @@ function isTextLikeResponse(headers: http.IncomingHttpHeaders): boolean { || contentType.includes('graphql'); } +// Only buffer-and-redact bounded, reasonably small text bodies. Anything we +// can't size up front (SSE, chunked streams) or that's too large is streamed +// straight through — buffering it would break streaming (e.g. LLM token-by-token +// responses hang until complete) for a low-value protection: the injected secret +// is in the request, not the response. Header redaction still applies regardless. +const MAX_REDACT_BODY_BYTES = 2 * 1024 * 1024; + +function isStreamingResponse(headers: http.IncomingHttpHeaders): boolean { + const contentType = getHeaderValue(headers, 'content-type')?.toLowerCase() ?? ''; + return contentType.includes('text/event-stream'); +} + +function isBoundedRedactableBody(headers: http.IncomingHttpHeaders): boolean { + const lenRaw = getHeaderValue(headers, 'content-length'); + if (lenRaw === undefined) return false; // unknown size — treat as a stream, never buffer + const len = Number(lenRaw); + return Number.isFinite(len) && len >= 0 && len <= MAX_REDACT_BODY_BYTES; +} + function shouldRedactResponseBody(headers: http.IncomingHttpHeaders): boolean { - return isUncompressedResponse(headers) && isTextLikeResponse(headers); + return isUncompressedResponse(headers) + && isTextLikeResponse(headers) + && !isStreamingResponse(headers) + && isBoundedRedactableBody(headers); } function redactOutgoingHeaders( @@ -181,114 +226,6 @@ function forwardUpstreamResponseWithRedaction( }); } -function sanitizeHostForFilename(host: string): string { - return host.replaceAll(/[^a-zA-Z0-9.-]/g, '_'); -} - -function runOpenssl(args: Array) { - execFileSync('openssl', args, { - stdio: 'ignore', - }); -} - -async function createProxyCa(certsDir: string): Promise<{ - caKeyPath: string; - caCertPath: string; - combinedCaPath: string; -}> { - const caKeyPath = path.join(certsDir, 'ca-key.pem'); - const caCertPath = path.join(certsDir, 'ca-cert.pem'); - const combinedCaPath = path.join(certsDir, 'combined-ca.pem'); - - runOpenssl([ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-sha256', - '-nodes', - '-days', - '3', - '-keyout', - caKeyPath, - '-out', - caCertPath, - '-subj', - '/CN=varlock-proxy-ca', - ]); - - const [caCertContents] = await Promise.all([readFile(caCertPath, 'utf8')]); - const combined = `${caCertContents}\n${tls.rootCertificates.join('\n')}\n`; - await writeFile(combinedCaPath, combined, 'utf8'); - - return { caKeyPath, caCertPath, combinedCaPath }; -} - -async function createHostCert( - certsDir: string, - host: string, - caKeyPath: string, - caCertPath: string, -): Promise<{ - key: Buffer; - cert: Buffer; - context: tls.SecureContext; -}> { - const safeHost = sanitizeHostForFilename(host); - const hostKeyPath = path.join(certsDir, `${safeHost}.key.pem`); - const hostCsrPath = path.join(certsDir, `${safeHost}.csr.pem`); - const hostCertPath = path.join(certsDir, `${safeHost}.cert.pem`); - const extPath = path.join(certsDir, `${safeHost}.ext.cnf`); - const serialPath = path.join(certsDir, 'ca.srl'); - - await writeFile(extPath, `subjectAltName=DNS:${host}\n`, 'utf8'); - - runOpenssl([ - 'req', - '-new', - '-newkey', - 'rsa:2048', - '-nodes', - '-keyout', - hostKeyPath, - '-out', - hostCsrPath, - '-subj', - `/CN=${host}`, - ]); - - runOpenssl([ - 'x509', - '-req', - '-in', - hostCsrPath, - '-CA', - caCertPath, - '-CAkey', - caKeyPath, - '-CAserial', - serialPath, - '-CAcreateserial', - '-out', - hostCertPath, - '-days', - '3', - '-sha256', - '-extfile', - extPath, - ]); - - const [key, cert] = await Promise.all([ - readFile(hostKeyPath), - readFile(hostCertPath), - ]); - return { - key, - cert, - context: tls.createSecureContext({ key, cert }), - }; -} - async function readBody(req: http.IncomingMessage): Promise { const chunks: Array = []; for await (const chunk of req) { @@ -311,8 +248,14 @@ export async function startLocalProxyRuntime({ egressMode, onActivity, }: StartLocalProxyRuntimeInput): Promise { + // Only the public CA cert is written to disk (for child trust). Private keys + // — the CA's and every per-host leaf's — stay in memory; see cert-authority.ts. const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); - const { caKeyPath, caCertPath, combinedCaPath } = await createProxyCa(certsDir); + const ca = await createEphemeralCa(); + const caCertPath = path.join(certsDir, 'ca-cert.pem'); + const combinedCaPath = path.join(certsDir, 'combined-ca.pem'); + await writeFile(caCertPath, ca.certPem, 'utf8'); + await writeFile(combinedCaPath, `${ca.certPem}\n${tls.rootCertificates.join('\n')}\n`, 'utf8'); const handleInterceptRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => { const hostHeader = req.headers.host ?? ''; @@ -338,25 +281,26 @@ export async function startLocalProxyRuntime({ matched: shouldRewrite, blocked: false, }); + const hostItems = shouldRewrite ? getHostScopedManagedItems(hostInfo.host, rules, managedItems) : []; const body = await readBody(req); - const bodyString = body.toString('utf8'); - const rewrittenBody = shouldRewrite ? replacePlaceholdersWithReal(bodyString, managedItems) : bodyString; + const rewrittenBody = shouldRewrite + ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') + : body; const upstreamHeaders = transformHeaders( req.headers, shouldRewrite - ? (value) => replacePlaceholdersWithReal(value, managedItems) + ? (value) => replacePlaceholdersWithReal(value, hostItems) : (value) => value, ); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; - const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(req.url ?? '/', managedItems) + ? buildPathnameAndQuery(req.url ?? '/', hostItems) : (req.url ?? '/'); - if (rewrittenBody.length !== body.length) { - upstreamHeaders['content-length'] = String(Buffer.byteLength(rewrittenBody)); + if (rewrittenBody.byteLength !== body.byteLength) { + upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); } const upstreamReq = https.request({ @@ -370,7 +314,7 @@ export async function startLocalProxyRuntime({ forwardUpstreamResponseWithRedaction( upstreamRes, res, - managedItems, + hostItems, shouldRewrite, ); }); @@ -388,10 +332,10 @@ export async function startLocalProxyRuntime({ const cached = hostMitmServers.get(normalized); if (cached) return cached; - const hostCert = await createHostCert(certsDir, normalized, caKeyPath, caCertPath); + const hostCert = await createHostCert(ca, normalized); const server = https.createServer({ - key: hostCert.key, - cert: hostCert.cert, + key: hostCert.keyPem, + cert: hostCert.certPem, ALPNProtocols: ['http/1.1'], }, (req, res) => { handleInterceptRequest(req, res).catch(() => { @@ -452,27 +396,28 @@ export async function startLocalProxyRuntime({ blocked: false, }); const isHttps = destination.protocol === 'https:'; + const hostItems = shouldRewrite ? getHostScopedManagedItems(destination.hostname, rules, managedItems) : []; const body = await readBody(clientReq); - const bodyString = body.toString('utf8'); - const rewrittenBody = shouldRewrite ? replacePlaceholdersWithReal(bodyString, managedItems) : bodyString; + const rewrittenBody = shouldRewrite + ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') + : body; const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(`${destination.pathname}${destination.search}`, managedItems) + ? buildPathnameAndQuery(`${destination.pathname}${destination.search}`, hostItems) : `${destination.pathname}${destination.search}`; const upstreamHeaders = transformHeaders( clientReq.headers, shouldRewrite - ? (value) => replacePlaceholdersWithReal(value, managedItems) + ? (value) => replacePlaceholdersWithReal(value, hostItems) : (value) => value, ); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; upstreamHeaders.host = destination.host; - - if (rewrittenBody.length !== body.length) { - upstreamHeaders['content-length'] = String(Buffer.byteLength(rewrittenBody)); + if (rewrittenBody.byteLength !== body.byteLength) { + upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); } const upstream = (isHttps ? https : http).request({ @@ -486,7 +431,7 @@ export async function startLocalProxyRuntime({ forwardUpstreamResponseWithRedaction( upstreamRes, clientRes, - managedItems, + hostItems, shouldRewrite, ); }); @@ -513,7 +458,10 @@ export async function startLocalProxyRuntime({ matched: shouldRewrite, blocked: true, }); - clientSocket.write('HTTP/1.1 403 Forbidden\r\nContent-Length: 34\r\nConnection: close\r\n\r\nProxy egress blocked by strict mode'); + const blockedBody = 'Proxy egress blocked by strict mode'; + clientSocket.write( + `HTTP/1.1 403 Forbidden\r\nContent-Length: ${Buffer.byteLength(blockedBody)}\r\nConnection: close\r\n\r\n${blockedBody}`, + ); clientSocket.destroy(); return; } diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index ba0022074..b957d7b9f 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -1,10 +1,12 @@ import crypto from 'node:crypto'; +import { existsSync } from 'node:fs'; import { mkdir, readdir, readFile, rm, writeFile, } from 'node:fs/promises'; import { join } from 'node:path'; import { getUserVarlockDir } from '../lib/user-config-dir'; +import { getAncestorPids } from './process-ancestry'; import type { ProxyEgressMode } from './types'; import { PROXY_CHILD_ENV_VAR, @@ -183,11 +185,42 @@ export async function getProxySessionByToken(token: string): Promise { + // Fast exit for the common case (proxy never used): don't create or read the + // sessions dir on every varlock invocation. + if (!existsSync(SESSIONS_DIR)) return undefined; + + const sessions = await listProxySessions(); + if (!sessions.length) return undefined; + const sessionToken = env[PROXY_SESSION_ID_ENV_VAR] ?? env[PROXY_SESSION_UUID_ENV_VAR]; - if (!sessionToken) return undefined; - const session = await getProxySessionByToken(sessionToken); + if (sessionToken) { + const byToken = sessions.find((s) => s.uuid === sessionToken || s.id === sessionToken); + if (byToken) return byToken; + } + + const ancestors = new Set(getAncestorPids()); + return sessions.find( + (s) => ancestors.has(s.ownerPid) || (s.childPid !== undefined && ancestors.has(s.childPid)), + ); +} + +export async function getProxyPlaceholderOverridesForEnv(env: NodeJS.ProcessEnv = process.env) { + const session = await resolveActiveProxySession(env); if (!session?.placeholderOverrides) return undefined; return session.placeholderOverrides; } diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index f2548d76b..caa486efb 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -17,4 +17,6 @@ export type ProxyManagedItem = { key: string; placeholder: string; realValue: string; + /** True when the placeholder is the generic format-agnostic fallback (may fail SDK key-format checks). */ + placeholderIsGenericFallback?: boolean; }; From 641e62c9f16cbddc6d39e36e47a73ad9ef109383 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 11 Jun 2026 23:44:33 -0700 Subject: [PATCH 05/64] feat(varlock): proxy security invariants (verified-identity injection, cleartext guard, response scrub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invariant #1: bind secret injection to the verified upstream TLS identity (public-PKI validation + cert identity matches the rule host, optional per-rule cert pinning). DNS-poison / rebound host → failed connection, never a leaked secret. Regression test included. - Invariant #2/#5: refuse to inject a secret into a cleartext (http://) connection; fail closed. Upstream-error path tears down the client connection rather than half-delivering. - Invariant #6: scrub real values back to placeholders in responses, now including streamed (SSE) text scrubbed chunk-by-chunk without breaking streaming; bounded-response post-scrub fail-safe withholds rather than leaks. - Tests moved onto the real HTTPS/MITM path (injection, redaction, per-item scoping, DNS-poison, cleartext guard, SSE stream + scrub). --- .bumpy/proxy-phase1-guardrails.md | 4 + packages/varlock/src/proxy/proxy-tls.test.ts | 173 ++++++++++++++++++ .../varlock/src/proxy/runtime-proxy.test.ts | 105 +++-------- packages/varlock/src/proxy/runtime-proxy.ts | 156 +++++++++++++++- packages/varlock/src/proxy/types.ts | 7 + 5 files changed, 361 insertions(+), 84 deletions(-) diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md index 58c4c2668..fe36cc31a 100644 --- a/.bumpy/proxy-phase1-guardrails.md +++ b/.bumpy/proxy-phase1-guardrails.md @@ -4,6 +4,10 @@ varlock: patch Add proxy guardrails and strict egress enforcement for run --proxy. +Verified-upstream-identity binding (Invariant #1): secrets are injected only onto upstream TLS connections that validate against the public PKI AND whose cert identity matches the rule-targeted host (with optional per-rule cert pinning). A DNS-poisoned / rebound host can't present a valid cert for the target, so it causes a failed connection, never a leaked secret. Cleartext guard (Invariant #2): refuse to inject a secret into a non-TLS (http://) connection; fail closed. Upstream-error path now fails closed (tears down the client connection) rather than half-delivering. + +Response scrubbing (Invariant #6): real values reflected in responses are replaced back to placeholders — now including streamed (SSE/chunked) text responses, scrubbed chunk-by-chunk without buffering (so a secret echoed in a stream never reaches the child while streaming still works). Bounded responses gain a post-scrub fail-safe: if a real value somehow survives redaction, the response is withheld rather than leaked. Compressed/binary bodies still pass through unscanned (documented residual). + Local-MVP hardening: per-item domain scoping (an item's secret is injected only on hosts its own rule matches), response redaction of headers + uncompressed bodies (compressed bodies pass through — most APIs don't reflect credentials, and forcing identity on every request wasn't worth the cost), schema-fingerprint enforcement on nested commands (closes the @sensitive-downgrade re-load), and correctness fixes (byte-accurate content-length, strict-mode 403 length). In-memory ephemeral CA: cert generation moved from the `openssl` subprocess to `@peculiar/x509` over WebCrypto (EC P-256). CA and per-host leaf private keys are now generated and held in memory — only the public CA cert is written to disk for child trust. Removes the openssl dependency (portability for the compiled binary) and closes the on-disk private-key exposure. Leaf certs for IP-literal ruled domains now use an IP SAN (clients verify IPs against iPAddress, not dNSName), so IP-based `@proxy` domains work. Validated end-to-end through the compiled binary (real HTTPS MITM) and via an in-process TLS integration test. diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index a31f8a128..005f938e3 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -92,6 +92,23 @@ async function openMitmTunnel( return tlsSocket; } +// Write a raw HTTP request over the tunnel and read the response (the MITM +// connection may stay keep-alive, so settle on idle rather than socket close). +async function sendAndRead(tlsSocket: tls.TLSSocket, rawRequest: string): Promise { + return new Promise((resolve, reject) => { + let buf = ''; + let idle: ReturnType; + tlsSocket.on('data', (c: Buffer) => { + buf += c.toString('utf8'); + clearTimeout(idle); + idle = setTimeout(() => resolve(buf), 250); + }); + tlsSocket.on('end', () => resolve(buf)); + tlsSocket.on('error', reject); + tlsSocket.write(rawRequest); + }); +} + describe('proxy HTTPS MITM (end-to-end)', () => { test('client trusts the minted leaf and the real key is injected upstream', async () => { let upstreamAuthHeader = ''; @@ -187,4 +204,160 @@ describe('proxy HTTPS MITM (end-to-end)', () => { await runtime.stop(); await upstream.close(); }); + + test('does NOT inject when the upstream cert is for a different host (Invariant #1 / DNS-poison)', async () => { + // The upstream listens on 127.0.0.1 but presents a cert for a DIFFERENT + // name — exactly what a DNS-poisoned / rebound host does, since it cannot + // obtain a valid cert for the host the rule targets. + const wrongLeaf = await createHostCert(upstreamCa, 'wrong.example'); + let upstreamGotRequest = false; + let upstreamAuth = ''; + const server = https.createServer({ key: wrongLeaf.keyPem, cert: wrongLeaf.certPem }, (req, res) => { + upstreamGotRequest = true; + upstreamAuth = String(req.headers.authorization ?? ''); + res.statusCode = 200; + res.end('ok'); + }); + await new Promise((res) => { + server.listen(0, UPSTREAM_HOST, () => res()); + }); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('no upstream addr'); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], + rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, addr.port); + // The connection fails closed (reset/closed), so don't depend on reading a + // response — assert the security property on the upstream side instead. + tlsSocket.on('error', () => { /* expected: connection torn down */ }); + tlsSocket.write( + `GET / HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${addr.port}\r\nConnection: close\r\nAuthorization: Bearer sk-stub-PLACEHOLDER\r\n\r\n`, + ); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + // Upstream identity didn't match the dialed host → the request (and its + // injected secret) was never transmitted upstream. This is the DNS-poison + // defense: a host that can't prove its identity gets no secret. + expect(upstreamGotRequest).toBe(false); + expect(upstreamAuth).not.toContain('sk-stub-REALKEY'); + + tlsSocket.destroy(); + await runtime.stop(); + await new Promise((res) => { + server.close(() => res()); + }); + }); + + test('redacts real values out of responses back to placeholders', async () => { + const REAL = 'sk-stub-REALKEY'; + const upstream = await startUpstream((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('x-echo-secret', `token=${REAL}`); + res.end(JSON.stringify({ apiKey: REAL })); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: REAL }], + rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + const response = await sendAndRead( + tlsSocket, + `GET / HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\nAuthorization: Bearer sk-stub-PLACEHOLDER\r\n\r\n`, + ); + + // The real value the upstream echoed (body + header) is scrubbed back to the + // placeholder before it reaches the client. + expect(response).toContain('sk-stub-PLACEHOLDER'); + expect(response).not.toContain(REAL); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); + + test('scrubs real values out of a streamed (SSE) response (Invariant #6)', async () => { + const REAL = 'sk-stub-REALKEY'; + const upstream = await startUpstream((_req, res) => { + res.statusCode = 200; + res.setHeader('content-type', 'text/event-stream'); + res.setHeader('cache-control', 'no-cache'); + res.write(`data: {"echoed":"${REAL}"}\n\n`); + setTimeout(() => { + res.write('data: done\n\n'); + res.end(); + }, 100); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: REAL }], + rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + const response = await sendAndRead( + tlsSocket, + `GET /stream HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\nAuthorization: Bearer sk-stub-PLACEHOLDER\r\n\r\n`, + ); + + // A secret reflected in the SSE stream is scrubbed chunk-by-chunk — the child + // never sees the real value, even though the response wasn't buffered. + expect(response).toContain('sk-stub-PLACEHOLDER'); + expect(response).not.toContain(REAL); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); + + test('only injects an item on hosts its own rule matches (per-item domain scoping)', async () => { + let receivedXTest = ''; + const upstream = await startUpstream((req, res) => { + receivedXTest = String(req.headers['x-test'] ?? ''); + res.statusCode = 200; + res.end('ok'); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [ + { key: 'ITEM_A', placeholder: 'PH_A_xxxxx', realValue: 'REAL_A_secret' }, + { key: 'ITEM_B', placeholder: 'PH_B_xxxxx', realValue: 'REAL_B_secret' }, + ], + rules: [ + { source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['ITEM_A'] }, + { source: 'attached', domain: ['other-host.example'], itemKeys: ['ITEM_B'] }, + ], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + await sendAndRead( + tlsSocket, + `GET / HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\nx-test: a=PH_A_xxxxx;b=PH_B_xxxxx\r\n\r\n`, + ); + + // ITEM_A's rule matches this host → injected. ITEM_B's rule is for a + // different host → its placeholder passes through untouched (no leak). + expect(receivedXTest).toContain('REAL_A_secret'); + expect(receivedXTest).toContain('PH_B_xxxxx'); + expect(receivedXTest).not.toContain('REAL_B_secret'); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 79cc30e20..f59117d6a 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -69,68 +69,14 @@ describe('startLocalProxyRuntime', () => { await runtime.stop(); }); - test('redacts matched response headers and body', async () => { - const secret = 'real-secret-value'; - const placeholder = 'placeholder-value'; - const upstream = http.createServer((_req, res) => { - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.setHeader('x-upstream-secret', `token=${secret}`); - res.end(JSON.stringify({ - ok: true, - apiKey: secret, - })); - }); - - await new Promise((resolve) => { - upstream.listen(0, '127.0.0.1', () => resolve()); - }); - const addr = upstream.address(); - if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); - - const runtime = await startLocalProxyRuntime({ - managedItems: [ - { - key: 'API_KEY', - placeholder, - realValue: secret, - }, - ], - rules: [ - { - source: 'attached', - domain: ['127.0.0.1'], - itemKeys: ['API_KEY'], - }, - ], - egressMode: 'permissive', - }); - - const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`); - - expect(response.statusCode).toBe(200); - expect(response.body).toContain(placeholder); - expect(response.body).not.toContain(secret); - expect(String(response.headers['x-upstream-secret'])).toContain(placeholder); - expect(String(response.headers['x-upstream-secret'])).not.toContain(secret); - - await runtime.stop(); - await new Promise((resolve) => { - upstream.close(() => { - resolve(); - }); - }); - }); - - test('only injects an item on hosts its own rule matches (per-item domain scoping)', async () => { - // Capture exactly what the upstream receives, so we can see what the proxy - // forwarded (the response gets re-redacted, so it can't be observed there). - let receivedXTest = ''; + test('refuses to inject a secret into a cleartext (http) connection (Invariant #2)', async () => { + let upstreamGotRequest = false; + let upstreamAuth = ''; const upstream = http.createServer((req, res) => { - receivedXTest = String(req.headers['x-test'] ?? ''); + upstreamGotRequest = true; + upstreamAuth = String(req.headers.authorization ?? ''); res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: true })); + res.end('ok'); }); await new Promise((resolve) => { upstream.listen(0, '127.0.0.1', () => resolve()); @@ -139,28 +85,21 @@ describe('startLocalProxyRuntime', () => { if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); const runtime = await startLocalProxyRuntime({ - managedItems: [ - { key: 'ITEM_A', placeholder: 'PH_A_xxxxx', realValue: 'REAL_A_secret' }, - { key: 'ITEM_B', placeholder: 'PH_B_xxxxx', realValue: 'REAL_B_secret' }, - ], - rules: [ - // ITEM_A is scoped to the request host; ITEM_B to a different host. - { source: 'attached', domain: ['127.0.0.1'], itemKeys: ['ITEM_A'] }, - { source: 'attached', domain: ['other-host.example'], itemKeys: ['ITEM_B'] }, - ], + managedItems: [{ key: 'API_KEY', placeholder: 'PH_placeholder', realValue: 'sk-REAL-secret' }], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); - await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`, { - 'x-test': 'a=PH_A_xxxxx;b=PH_B_xxxxx', + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`, { + authorization: 'Bearer PH_placeholder', }); - // ITEM_A's rule matches this host → its placeholder is swapped for the real value. - expect(receivedXTest).toContain('REAL_A_secret'); - // ITEM_B's rule is for a different host → its placeholder must pass through - // untouched, never substituted with the real value (no cross-credential leak). - expect(receivedXTest).toContain('PH_B_xxxxx'); - expect(receivedXTest).not.toContain('REAL_B_secret'); + // Fail closed: a ruled item over a cleartext connection is refused, and the + // real secret never reaches the (un-TLS'd) upstream. + expect(response.statusCode).toBe(403); + expect(response.body).toContain('cleartext'); + expect(upstreamGotRequest).toBe(false); + expect(upstreamAuth).not.toContain('sk-REAL-secret'); await runtime.stop(); await new Promise((resolve) => { @@ -183,8 +122,10 @@ describe('startLocalProxyRuntime', () => { if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); const runtime = await startLocalProxyRuntime({ - managedItems: [{ key: 'API_KEY', placeholder: 'PH', realValue: 'secret' }], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + // No injected items — these tests exercise forwarding/streaming behavior, + // not injection (which now requires TLS, see proxy-tls.test.ts). + managedItems: [], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', }); @@ -221,8 +162,10 @@ describe('startLocalProxyRuntime', () => { if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); const runtime = await startLocalProxyRuntime({ - managedItems: [{ key: 'API_KEY', placeholder: 'PH', realValue: 'secret' }], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + // No injected items — these tests exercise forwarding/streaming behavior, + // not injection (which now requires TLS, see proxy-tls.test.ts). + managedItems: [], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 5133641ae..35bcd7770 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -6,6 +6,8 @@ import https from 'node:https'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; +import { Transform } from 'node:stream'; +import { StringDecoder } from 'node:string_decoder'; import tls from 'node:tls'; import { URL } from 'node:url'; @@ -59,6 +61,56 @@ function hostMatchesProxyRules(host: string, rules: Array): boolean { return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); } +function normalizeFingerprint(fingerprint: string): string { + return fingerprint.replace(/:/g, '').toLowerCase(); +} + +function getCertPinsForHost(host: string, rules: Array): Set { + const pins = new Set(); + for (const rule of rules) { + if (!rule.pin?.length) continue; + if (rule.domain.some((d) => domainMatches(d, host))) { + for (const pin of rule.pin) pins.add(normalizeFingerprint(pin)); + } + } + return pins; +} + +/** + * Invariant #1 (+ #4): bind secret injection to the *verified upstream TLS + * identity*, not the requested name. Returns TLS options for the secret-bearing + * upstream request that (a) require validation against the public PKI + * (`rejectUnauthorized`), (b) confirm the cert identity matches the host we + * dialed (which is the rule-matched host), and (c) enforce any per-rule cert + * pinning. On any mismatch the handshake fails, so the already-substituted + * request is never transmitted — a poisoned DNS/Host name causes a failed + * connection, never a leaked secret. + */ +function buildVerifiedUpstreamOptions(host: string, rules: Array): { + rejectUnauthorized: true; + checkServerIdentity: (servername: string, cert: tls.PeerCertificate) => Error | undefined; +} { + const pins = getCertPinsForHost(host, rules); + // Deliberately do NOT set `servername` — Node derives it from the request + // hostname (SNI for DNS names; omitted for IP literals, where setting it + // throws) and still passes the host to checkServerIdentity, so identity is + // verified against the host we dialed (= the rule-matched host) either way. + return { + rejectUnauthorized: true, + checkServerIdentity: (servername, cert) => { + const identityError = tls.checkServerIdentity(servername, cert); + if (identityError) return identityError; + if (pins.size) { + const actual = normalizeFingerprint(String(cert.fingerprint256 ?? '')); + if (!actual || !pins.has(actual)) { + return new Error(`Upstream certificate for ${servername} did not match a pinned fingerprint`); + } + } + return undefined; + }, + }; +} + /** * Resolve the managed items that are in scope for a given host: only items * referenced by a rule whose domain matches this host. This enforces per-item @@ -184,6 +236,58 @@ function redactOutgoingHeaders( ); } +/** Returns the first managed item whose real value still appears in `text` (a leak), if any. */ +function findRealLeak(text: string, managedItems: Array): ProxyManagedItem | undefined { + return managedItems.find((item) => item.realValue.length > 0 && text.includes(item.realValue)); +} + +/** + * Length of the longest suffix of `text` that is a strict prefix of some real + * value — i.e. a partial real value that might complete in the next chunk and + * so must be held back. Returns 0 (emit everything) when the text doesn't end + * mid-secret, which keeps streaming responsive instead of buffering a fixed + * window every chunk. + */ +function pendingRealPrefixLen(text: string, managedItems: Array): number { + let best = 0; + for (const item of managedItems) { + const real = item.realValue; + if (!real) continue; + const maxK = Math.min(real.length - 1, text.length); + for (let k = maxK; k > best; k -= 1) { + if (text.endsWith(real.slice(0, k))) { + best = k; + break; + } + } + } + return best; +} + +/** + * Scrub real values back to placeholders on an *unbounded text stream* (e.g. + * SSE), chunk by chunk, so a reflected secret in a streamed response is still + * replaced for the child without buffering the whole stream. A StringDecoder + * keeps multi-byte UTF-8 chars intact across chunks; only a trailing *partial* + * real value is held back, so complete chunks flow through immediately. + */ +function createScrubbingTransform(managedItems: Array): Transform { + const decoder = new StringDecoder('utf8'); + let carry = ''; + return new Transform({ + transform(chunk, _enc, cb) { + const scrubbed = replaceRealWithPlaceholders(carry + decoder.write(chunk as Buffer), managedItems); + const hold = pendingRealPrefixLen(scrubbed, managedItems); + const emitLen = scrubbed.length - hold; + carry = scrubbed.slice(emitLen); + cb(null, Buffer.from(scrubbed.slice(0, emitLen), 'utf8')); + }, + flush(cb) { + cb(null, Buffer.from(replaceRealWithPlaceholders(carry + decoder.end(), managedItems), 'utf8')); + }, + }); +} + function forwardUpstreamResponseWithRedaction( upstreamRes: http.IncomingMessage, clientRes: http.ServerResponse, @@ -196,8 +300,23 @@ function forwardUpstreamResponseWithRedaction( : { ...upstreamRes.headers }; if (!shouldRedact || !shouldRedactResponseBody(upstreamRes.headers)) { + // Scrub unbounded uncompressed text streams (e.g. SSE) chunk-by-chunk so a + // reflected secret is still replaced. Bodies with a content-length take the + // buffered path below; compressed/binary bodies can't be scanned without + // decompressing and pass through unchanged. + const hasContentLength = getHeaderValue(upstreamRes.headers, 'content-length') !== undefined; + const canScrubStream = shouldRedact + && managedItems.length > 0 + && !hasContentLength + && isUncompressedResponse(upstreamRes.headers) + && isTextLikeResponse(upstreamRes.headers); + clientRes.writeHead(statusCode, outgoingHeaders); - upstreamRes.pipe(clientRes); + if (canScrubStream) { + upstreamRes.pipe(createScrubbingTransform(managedItems)).pipe(clientRes); + } else { + upstreamRes.pipe(clientRes); + } return; } @@ -209,6 +328,18 @@ function forwardUpstreamResponseWithRedaction( upstreamRes.on('end', () => { const originalBody = Buffer.concat(chunks).toString('utf8'); const redactedBody = replaceRealWithPlaceholders(originalBody, managedItems); + + // Fail-safe (Invariant #6): if a real value somehow survived scrubbing, do + // NOT forward it — fail closed rather than leak a secret to the child. + if (findRealLeak(redactedBody, managedItems)) { + if (!clientRes.headersSent) { + clientRes.writeHead(502, { 'content-type': 'text/plain', connection: 'close' }); + } + clientRes.end('Response withheld: a sensitive value could not be redacted'); + clientRes.socket?.destroy(); + return; + } + const redactedBuffer = Buffer.from(redactedBody, 'utf8'); const headersForWrite = { ...outgoingHeaders }; @@ -310,6 +441,7 @@ export async function startLocalProxyRuntime({ method: req.method, path: rewrittenPath, headers: upstreamHeaders, + ...buildVerifiedUpstreamOptions(hostInfo.host, rules), }, (upstreamRes) => { forwardUpstreamResponseWithRedaction( upstreamRes, @@ -320,8 +452,16 @@ export async function startLocalProxyRuntime({ }); upstreamReq.on('error', () => { - if (!res.headersSent) res.statusCode = 502; - res.end('Upstream MITM request failed'); + // Fail closed: the upstream identity could not be verified (or the + // connection failed), so the secret was never transmitted. Tear the + // client connection down rather than risk a half-delivered response. + if (!res.headersSent) { + try { + res.writeHead(502, { 'content-type': 'text/plain', connection: 'close' }); + res.end('Upstream MITM request failed'); + } catch { /* response may already be gone */ } + } + res.socket?.destroy(); }); upstreamReq.end(rewrittenBody); }; @@ -398,6 +538,15 @@ export async function startLocalProxyRuntime({ const isHttps = destination.protocol === 'https:'; const hostItems = shouldRewrite ? getHostScopedManagedItems(destination.hostname, rules, managedItems) : []; + // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) + // connection — no cert means no verifiable identity. Fail closed. + if (hostItems.length > 0 && !isHttps) { + onActivity?.({ matched: true, blocked: true }); + clientRes.statusCode = 403; + clientRes.end('Refusing to inject secrets into a cleartext (non-TLS) connection'); + return; + } + const body = await readBody(clientReq); const rewrittenBody = shouldRewrite ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') @@ -427,6 +576,7 @@ export async function startLocalProxyRuntime({ method: clientReq.method, path: rewrittenPath, headers: upstreamHeaders, + ...(isHttps ? buildVerifiedUpstreamOptions(destination.hostname, rules) : {}), }, (upstreamRes) => { forwardUpstreamResponseWithRedaction( upstreamRes, diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index caa486efb..8f8ebf8e5 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -11,6 +11,13 @@ export type ProxyRule = { block?: boolean; sign?: string; transform?: string; + /** + * Optional per-rule certificate pinning (Invariant #4). Expected upstream + * cert SHA-256 fingerprints; if set, the upstream cert must match one of them + * in addition to validating against the public PKI. Closes the residual hole + * where any mis-issued-but-publicly-trusted cert would otherwise pass. + */ + pin?: Array; }; export type ProxyManagedItem = { From ca943c68f3b4f25c34e74adc3d0d71a195fe352b Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 11 Jun 2026 23:54:54 -0700 Subject: [PATCH 06/64] feat(varlock): proxy per-call policy (path/method matching + block-deny + scoped injection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a facts→verdict policy layer (packages/varlock/src/proxy/policy.ts): - Requests are normalized to facts (host + method + path) and evaluated against @proxy rules. A matching block=true rule denies the request (fail closed — never reaches upstream): static per-call authorization. - Credential injection is scoped by path/method, not just host (getRequestScopedManagedItems), so a secret can be limited to specific endpoints/methods. - Glob path matching (* within a segment, ** across); comma-separated methods. - Modeled as facts→verdict (allow|deny|require-approval) over a generic fact bag so domain plugins / non-HTTP protocols slot onto the same seam later. The require-approval verdict + approval provider is a follow-up. Note: short MITM-tunnel responses (deny 403, upstream-error 502) don't flush reliably through the CONNECT tunnel, so they fail closed by tearing the connection down. Clean status-code delivery over the tunnel is a follow-up. Tests: policy unit tests (matching, block precedence, scoped injection) + end-to-end block-deny over the HTTPS/MITM path. --- .bumpy/proxy-phase1-guardrails.md | 2 + packages/varlock/src/proxy/policy.test.ts | 72 ++++++++++++ packages/varlock/src/proxy/policy.ts | 113 +++++++++++++++++++ packages/varlock/src/proxy/proxy-tls.test.ts | 39 +++++++ packages/varlock/src/proxy/runtime-proxy.ts | 61 ++++++---- 5 files changed, 262 insertions(+), 25 deletions(-) create mode 100644 packages/varlock/src/proxy/policy.test.ts create mode 100644 packages/varlock/src/proxy/policy.ts diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md index fe36cc31a..66700f4ce 100644 --- a/.bumpy/proxy-phase1-guardrails.md +++ b/.bumpy/proxy-phase1-guardrails.md @@ -8,6 +8,8 @@ Verified-upstream-identity binding (Invariant #1): secrets are injected only ont Response scrubbing (Invariant #6): real values reflected in responses are replaced back to placeholders — now including streamed (SSE/chunked) text responses, scrubbed chunk-by-chunk without buffering (so a secret echoed in a stream never reaches the child while streaming still works). Bounded responses gain a post-scrub fail-safe: if a real value somehow survives redaction, the response is withheld rather than leaked. Compressed/binary bodies still pass through unscanned (documented residual). +Per-call policy / static authorization: requests are evaluated as facts (host + method + path) against the `@proxy` rules. A matching `block=true` rule denies the request (fail closed — never reaches upstream), and credential injection is now scoped by path/method too (`getRequestScopedManagedItems`), so a secret can be limited to specific endpoints/methods, not just a host. Modeled as `facts → verdict` (allow|deny|require-approval) with a generic fact bag so domain plugins / non-HTTP protocols slot onto the same seam later. Glob path matching (`*` within a segment, `**` across). + Local-MVP hardening: per-item domain scoping (an item's secret is injected only on hosts its own rule matches), response redaction of headers + uncompressed bodies (compressed bodies pass through — most APIs don't reflect credentials, and forcing identity on every request wasn't worth the cost), schema-fingerprint enforcement on nested commands (closes the @sensitive-downgrade re-load), and correctness fixes (byte-accurate content-length, strict-mode 403 length). In-memory ephemeral CA: cert generation moved from the `openssl` subprocess to `@peculiar/x509` over WebCrypto (EC P-256). CA and per-host leaf private keys are now generated and held in memory — only the public CA cert is written to disk for child trust. Removes the openssl dependency (portability for the compiled binary) and closes the on-disk private-key exposure. Leaf certs for IP-literal ruled domains now use an IP SAN (clients verify IPs against iPAddress, not dNSName), so IP-based `@proxy` domains work. Validated end-to-end through the compiled binary (real HTTPS MITM) and via an in-process TLS integration test. diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts new file mode 100644 index 000000000..29e0e9d06 --- /dev/null +++ b/packages/varlock/src/proxy/policy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; + +import { + evaluateProxyPolicy, getRequestScopedManagedItems, ruleMatchesFacts, type RequestFacts, +} from './policy'; +import type { ProxyManagedItem, ProxyRule } from './types'; + +const rule = (partial: Partial): ProxyRule => ({ + source: 'detached', domain: [], itemKeys: [], ...partial, +}); +const facts = (host: string, method: string, path: string): RequestFacts => ({ host, method, path }); + +describe('proxy policy matching', () => { + test('matches on domain + path glob + method', () => { + const r = rule({ domain: ['api.x.com'], path: '/v1/customers/*', method: 'GET' }); + expect(ruleMatchesFacts(r, facts('api.x.com', 'GET', '/v1/customers/42'))).toBe(true); + // method mismatch + expect(ruleMatchesFacts(r, facts('api.x.com', 'POST', '/v1/customers/42'))).toBe(false); + // `*` is a single segment — does not cross `/` + expect(ruleMatchesFacts(r, facts('api.x.com', 'GET', '/v1/customers/42/charges'))).toBe(false); + // domain mismatch + expect(ruleMatchesFacts(r, facts('evil.com', 'GET', '/v1/customers/42'))).toBe(false); + }); + + test('`**` matches across segments; comma methods; domain-only matches any path/method', () => { + expect(ruleMatchesFacts(rule({ domain: ['api.x.com'], path: '/v1/**' }), facts('api.x.com', 'GET', '/v1/a/b/c'))).toBe(true); + expect(ruleMatchesFacts(rule({ domain: ['api.x.com'], method: 'GET,POST' }), facts('api.x.com', 'POST', '/any'))).toBe(true); + expect(ruleMatchesFacts(rule({ domain: ['api.x.com'] }), facts('api.x.com', 'DELETE', '/whatever'))).toBe(true); + }); +}); + +describe('evaluateProxyPolicy', () => { + const rules = [ + rule({ domain: ['api.stripe.com'], itemKeys: ['STRIPE_KEY'] }), + rule({ + domain: ['api.stripe.com'], path: '/v1/charges', method: 'POST', block: true, + }), + ]; + + test('block rule denies the specific endpoint, allows the rest', () => { + expect(evaluateProxyPolicy(facts('api.stripe.com', 'POST', '/v1/charges'), rules).verdict).toBe('deny'); + expect(evaluateProxyPolicy(facts('api.stripe.com', 'GET', '/v1/customers'), rules).verdict).toBe('allow'); + }); + + test('block wins even when an allow rule also matches', () => { + const both = [ + rule({ domain: ['api.x.com'], itemKeys: ['K'] }), + rule({ domain: ['api.x.com'], path: '/danger', block: true }), + ]; + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/danger'), both).verdict).toBe('deny'); + }); +}); + +describe('getRequestScopedManagedItems', () => { + const items: Array = [{ key: 'K', placeholder: 'PH', realValue: 'REAL' }]; + + test('injects only when domain + path + method all match', () => { + const rules = [ + rule({ + domain: ['api.x.com'], path: '/v1/read/*', method: 'GET', itemKeys: ['K'], + }), + ]; + expect(getRequestScopedManagedItems(facts('api.x.com', 'GET', '/v1/read/1'), rules, items).map((i) => i.key)).toEqual(['K']); + expect(getRequestScopedManagedItems(facts('api.x.com', 'POST', '/v1/read/1'), rules, items)).toEqual([]); + expect(getRequestScopedManagedItems(facts('api.x.com', 'GET', '/v1/write/1'), rules, items)).toEqual([]); + }); + + test('a block rule never contributes injection items', () => { + const rules = [rule({ domain: ['api.x.com'], block: true, itemKeys: ['K'] })]; + expect(getRequestScopedManagedItems(facts('api.x.com', 'GET', '/'), rules, items)).toEqual([]); + }); +}); diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts new file mode 100644 index 000000000..615b970ef --- /dev/null +++ b/packages/varlock/src/proxy/policy.ts @@ -0,0 +1,113 @@ +import type { ProxyManagedItem, ProxyRule } from './types'; + +/** + * Normalized facts extracted from a request, the input to every policy + * decision. Kept deliberately generic (a "fact bag") so non-HTTP protocols and + * domain plugins can enrich it later without changing the evaluation interface. + */ +export type RequestFacts = { + host: string; + method: string; + /** Path only (no query string), e.g. `/v1/customers/42`. */ + path: string; +}; + +export type PolicyVerdict = 'allow' | 'deny' | 'require-approval'; + +export type PolicyDecision = { + verdict: PolicyVerdict; + matchedRule?: ProxyRule; + reason?: string; +}; + +function normalizeHost(host: string): string { + return host.toLowerCase().trim(); +} + +function domainMatches(domainPattern: string, host: string): boolean { + const pattern = normalizeHost(domainPattern); + const normalizedHost = normalizeHost(host); + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(2); + return normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`); + } + return normalizedHost === pattern; +} + +/** + * Glob path match: `*` matches within a single path segment, `**` matches + * across segments. Everything else is literal. `pathRegex` (a future option) + * would be the escape hatch for anything globs can't express. + */ +function pathMatches(pattern: string, path: string): boolean { + const re = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + // `**` → cross-segment, `*` → within-segment (alternation matches `**` first) + .replace(/\*\*|\*/g, (match) => (match === '**' ? '.*' : '[^/]*')); + return new RegExp(`^${re}$`).test(path); +} + +function methodMatches(ruleMethod: string, method: string): boolean { + const allowed = ruleMethod.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean); + return allowed.length === 0 || allowed.includes(method.toUpperCase()); +} + +/** Whether a rule's match constraints (domain + optional path + optional method) all hold for the facts. */ +export function ruleMatchesFacts(rule: ProxyRule, facts: RequestFacts): boolean { + if (!rule.domain.some((d) => domainMatches(d, facts.host))) return false; + if (rule.path !== undefined && !pathMatches(rule.path, facts.path)) return false; + if (rule.method !== undefined && !methodMatches(rule.method, facts.method)) return false; + return true; +} + +/** A rule with path/method constraints is more specific than a domain-only rule. */ +function ruleSpecificity(rule: ProxyRule): number { + return (rule.path !== undefined ? 2 : 0) + (rule.method !== undefined ? 1 : 0); +} + +/** + * Evaluate the policy for a request. Precedence: + * 1. any matching `block` rule wins → deny + * 2. otherwise allow (injection scoping is handled separately, so an + * allow verdict doesn't imply a secret is injected) + * + * `require-approval` is part of the verdict space so the approval layer drops in + * here later without changing callers; no rule produces it yet. + */ +export function evaluateProxyPolicy(facts: RequestFacts, rules: Array): PolicyDecision { + const matching = rules.filter((rule) => ruleMatchesFacts(rule, facts)); + + const blockRule = matching + .filter((rule) => rule.block) + .sort((a, b) => ruleSpecificity(b) - ruleSpecificity(a))[0]; + if (blockRule) { + return { verdict: 'deny', matchedRule: blockRule, reason: 'blocked by @proxy(block=true) rule' }; + } + + const allowRule = matching + .filter((rule) => !rule.block) + .sort((a, b) => ruleSpecificity(b) - ruleSpecificity(a))[0]; + return { verdict: 'allow', matchedRule: allowRule }; +} + +/** + * The managed items in scope for a specific request: items referenced by a + * non-block rule whose full match (domain + path + method) holds for the facts. + * This extends per-item domain scoping with path/method scoping, so a credential + * can be limited to specific endpoints/methods, not just a host. + */ +export function getRequestScopedManagedItems( + facts: RequestFacts, + rules: Array, + managedItems: Array, +): Array { + const allowedKeys = new Set(); + for (const rule of rules) { + if (rule.block) continue; + if (ruleMatchesFacts(rule, facts)) { + for (const key of rule.itemKeys) allowedKeys.add(key); + } + } + if (allowedKeys.size === 0) return []; + return managedItems.filter((item) => allowedKeys.has(item.key)); +} diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index 005f938e3..f0e9aa14a 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -323,6 +323,45 @@ describe('proxy HTTPS MITM (end-to-end)', () => { await upstream.close(); }); + test('denies a request matched by a block rule, never reaching upstream (static authz)', async () => { + let upstreamGotRequest = false; + const upstream = await startUpstream((_req, res) => { + upstreamGotRequest = true; + res.statusCode = 200; + res.end('ok'); + }); + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], + rules: [ + { source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }, + { + source: 'detached', domain: [UPSTREAM_HOST], path: '/v1/charges', method: 'POST', itemKeys: [], block: true, + }, + ], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + + const tlsSocket = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, upstream.port); + // Denied request fails closed (best-effort 403, then connection torn down), + // so assert the security guarantee on the upstream side. + tlsSocket.on('error', () => { /* expected: connection torn down */ }); + tlsSocket.write( + `POST /v1/charges HTTP/1.1\r\nHost: ${UPSTREAM_HOST}:${upstream.port}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + // The blocked endpoint never reached the upstream — static per-call authorization. + expect(upstreamGotRequest).toBe(false); + + tlsSocket.destroy(); + await runtime.stop(); + await upstream.close(); + }); + test('only injects an item on hosts its own rule matches (per-item domain scoping)', async () => { let receivedXTest = ''; const upstream = await startUpstream((req, res) => { diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 35bcd7770..16815f6d7 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -12,6 +12,9 @@ import tls from 'node:tls'; import { URL } from 'node:url'; import { createEphemeralCa, createHostCert } from './cert-authority'; +import { + evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, +} from './policy'; import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from './types'; const LOCALHOST = '127.0.0.1'; @@ -111,29 +114,6 @@ function buildVerifiedUpstreamOptions(host: string, rules: Array): { }; } -/** - * Resolve the managed items that are in scope for a given host: only items - * referenced by a rule whose domain matches this host. This enforces per-item - * domain scoping — an item's secret is injected (and its real value redacted on - * the way back) only on the hosts its own rule applies to, never on every ruled - * host. Prevents a child from harvesting item B's real value by reflecting B's - * placeholder into a request to item A's host. - */ -function getHostScopedManagedItems( - host: string, - rules: Array, - managedItems: Array, -): Array { - const allowedKeys = new Set(); - for (const rule of rules) { - if (rule.domain.some((d) => domainMatches(d, host))) { - for (const key of rule.itemKeys) allowedKeys.add(key); - } - } - if (allowedKeys.size === 0) return []; - return managedItems.filter((item) => allowedKeys.has(item.key)); -} - function replacePlaceholdersWithReal(value: string, managedItems: Array): string { let next = value; for (const item of managedItems) { @@ -408,11 +388,30 @@ export async function startLocalProxyRuntime({ res.end('Proxy egress blocked by strict mode'); return; } + // Per-call policy (Invariant: static authorization). Evaluate host + method + // + path against the rules; a matching `block` rule denies the request. + const facts: RequestFacts = { + host: hostInfo.host, + method: req.method ?? 'GET', + path: (req.url ?? '/').split('?')[0] ?? '/', + }; + if (shouldRewrite && evaluateProxyPolicy(facts, rules).verdict === 'deny') { + onActivity?.({ matched: true, blocked: true }); + // Fail closed: the request is denied and never reaches upstream. Best-effort + // 403, then tear down (short MITM-tunnel responses don't reliably flush). + try { + res.writeHead(403, { 'content-type': 'text/plain', connection: 'close' }); + res.end('Blocked by proxy policy'); + } catch { /* response may already be gone */ } + res.socket?.destroy(); + return; + } + onActivity?.({ matched: shouldRewrite, blocked: false, }); - const hostItems = shouldRewrite ? getHostScopedManagedItems(hostInfo.host, rules, managedItems) : []; + const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; const body = await readBody(req); const rewrittenBody = shouldRewrite ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') @@ -531,12 +530,24 @@ export async function startLocalProxyRuntime({ clientRes.end('Proxy egress blocked by strict mode'); return; } + const facts: RequestFacts = { + host: destination.hostname, + method: clientReq.method ?? 'GET', + path: destination.pathname, + }; + if (shouldRewrite && evaluateProxyPolicy(facts, rules).verdict === 'deny') { + onActivity?.({ matched: true, blocked: true }); + clientRes.statusCode = 403; + clientRes.end('Blocked by proxy policy'); + return; + } + onActivity?.({ matched: shouldRewrite, blocked: false, }); const isHttps = destination.protocol === 'https:'; - const hostItems = shouldRewrite ? getHostScopedManagedItems(destination.hostname, rules, managedItems) : []; + const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) // connection — no cert means no verifiable identity. Fail closed. From d9c1fca676cd7266c63b95f742b00b9d72ca4b28 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 18:22:37 -0700 Subject: [PATCH 07/64] feat(varlock): proxy append-only audit log (Invariant #7) Record one no-secrets JSON line per proxied request (host, method, path, request-hash, rule, decision, injected keys) under ~/.config/varlock/proxy/audit/.jsonl; view with `varlock proxy audit`. --- .bumpy/proxy-audit-log.md | 7 + .../varlock/src/cli/commands/proxy.command.ts | 93 ++++++++- packages/varlock/src/proxy/audit.test.ts | 188 ++++++++++++++++++ packages/varlock/src/proxy/audit.ts | 156 +++++++++++++++ packages/varlock/src/proxy/policy.ts | 13 ++ packages/varlock/src/proxy/proxy-tls.test.ts | 9 + .../varlock/src/proxy/runtime-proxy.test.ts | 123 ++++++++++++ packages/varlock/src/proxy/runtime-proxy.ts | 113 ++++++++--- 8 files changed, 662 insertions(+), 40 deletions(-) create mode 100644 .bumpy/proxy-audit-log.md create mode 100644 packages/varlock/src/proxy/audit.test.ts create mode 100644 packages/varlock/src/proxy/audit.ts diff --git a/.bumpy/proxy-audit-log.md b/.bumpy/proxy-audit-log.md new file mode 100644 index 000000000..15a43b3f4 --- /dev/null +++ b/.bumpy/proxy-audit-log.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Add an append-only audit log for proxy sessions (Invariant #7). + +Every proxied request now records one JSON line — timestamp, host, method, path, a request fingerprint hash, the matched rule, the decision (allow / deny / blocked-egress / blocked-cleartext), and which managed items were injected (by key name). No secret values, query strings, or request bodies are ever written. Logs are stored per session under `~/.config/varlock/proxy/audit/.jsonl` and persist after the session ends. View them with `varlock proxy audit [--session ] [--format text|json]`. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 54e3c0901..ae7dd1466 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -4,6 +4,12 @@ import { gracefulExit } from 'exit-hook'; import { exec } from '../../lib/exec'; import { loadVarlockEnvGraph } from '../../lib/load-graph'; import { startLocalProxyRuntime } from '../../proxy/runtime-proxy'; +import { + createProxyAuditLog, + readProxyAuditLines, + type ProxyAuditEntry, + type ProxyAuditLog, +} from '../../proxy/audit'; import { cleanupStaleProxySessions, createProxySessionRecord, @@ -46,7 +52,7 @@ export const commandSpec = define({ format: { type: 'string', short: 'f', - description: 'Output format (used by `proxy env`): shell (default) or json', + description: 'Output format: `proxy env` → shell (default) or json; `proxy audit` → text (default) or json', }, watch: { type: 'boolean', @@ -78,6 +84,7 @@ Proxy command surface: varlock proxy start varlock proxy env --session abc12 varlock proxy status + varlock proxy audit --session abc12 varlock proxy refresh --session abc12 varlock proxy stop --session abc12 varlock proxy stop --all @@ -191,7 +198,7 @@ function getAction(ctx: any): string { if (!action) { throw new CliExitError( 'Missing proxy action.', - { suggestion: 'Use one of: run, start, env, status, refresh, stop' }, + { suggestion: 'Use one of: run, start, env, status, audit, refresh, stop' }, ); } return action; @@ -285,16 +292,30 @@ async function createRuntimeAndSession(opts: { runtime: Awaited>; session: ProxySessionRecord; statsWriter: ReturnType; + auditLog: ProxyAuditLog; }> { const identity = await reserveProxySessionIdentity(); const statsWriter = createSessionStatsWriter(identity.uuid, EMPTY_PROXY_SESSION_STATS); + const now = new Date().toISOString(); + // Append-only audit log (Invariant #7): one entry per request decision, no + // secret values. Persists after the session record is deleted on cleanup. + const auditLog = createProxyAuditLog(identity.uuid, { + ts: now, + id: identity.id, + uuid: identity.uuid, + cwd: process.cwd(), + egressMode: opts.policy.egressMode, + ...(opts.command?.length ? { command: opts.command } : {}), + }); const runtime = await startLocalProxyRuntime({ managedItems: opts.policy.proxyManagedItems, rules: opts.policy.proxyRules, egressMode: opts.policy.egressMode, - onActivity: statsWriter.onActivity, + onActivity: (activity) => { + statsWriter.onActivity(activity); + auditLog.record(activity); + }, }); - const now = new Date().toISOString(); const session = await createProxySessionRecord({ id: identity.id, uuid: identity.uuid, @@ -314,7 +335,9 @@ async function createRuntimeAndSession(opts: { ...(opts.command?.length ? { command: opts.command } : {}), }); - return { runtime, session, statsWriter }; + return { + runtime, session, statsWriter, auditLog, + }; } function applyInjectModeFromFlags(ctx: any): { @@ -377,7 +400,9 @@ async function runAction(ctx: any) { const commandToRunStr = commandToRunAsArgs.join(' '); const policy = await prepareProxyPolicy(ctx.values.path); - const { runtime, session, statsWriter } = await createRuntimeAndSession({ + const { + runtime, session, statsWriter, auditLog, + } = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, command: commandToRunAsArgs, @@ -407,6 +432,7 @@ async function runAction(ctx: any) { await statsWriter.flushNow(); statsWriter.stop(); await runtime.stop().catch(() => undefined); + await auditLog.flush().catch(() => undefined); await deleteProxySessionRecord(session.uuid).catch(() => undefined); }; @@ -508,7 +534,9 @@ async function runAction(ctx: any) { async function startAction(ctx: any) { const policy = await prepareProxyPolicy(ctx.values.path); - const { runtime, session, statsWriter } = await createRuntimeAndSession({ + const { + runtime, session, statsWriter, auditLog, + } = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, }); @@ -524,6 +552,7 @@ async function startAction(ctx: any) { await statsWriter.flushNow(); statsWriter.stop(); await runtime.stop().catch(() => undefined); + await auditLog.flush().catch(() => undefined); await deleteProxySessionRecord(session.uuid).catch(() => undefined); }; @@ -676,6 +705,52 @@ async function stopAction(ctx: any) { } } +function formatAuditEntry(entry: ProxyAuditEntry): string { + const injected = entry.injected && entry.injectedKeys?.length + ? ` injected=${entry.injectedKeys.join(',')}` + : ''; + const rule = entry.ruleId ? ` rule="${entry.ruleId}"` : ''; + return `${entry.ts} ${entry.decision.padEnd(16)} ${entry.method.padEnd(7)} ${entry.host}${entry.path}${injected}${rule}`; +} + +async function auditAction(ctx: any) { + const format = (ctx.values.format ?? 'text').toLowerCase(); + if (format !== 'text' && format !== 'json') { + throw new CliExitError('Invalid --format for `proxy audit`. Use "text" or "json".'); + } + + // Resolve the session uuid. Prefer the live registry (accepts short ids and + // ancestry), but fall back to treating an explicit value as a uuid, since the + // audit log outlives the (deleted-on-stop) session record. + let uuid: string; + try { + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: true, + }); + uuid = session.uuid; + } catch (error) { + if (!ctx.values.session) throw new CliExitError((error as Error).message); + uuid = ctx.values.session; + } + + const lines = await readProxyAuditLines(uuid); + if (format === 'json') { + console.log(JSON.stringify(lines, null, 2)); + return; + } + + const entries = lines.filter((line): line is ProxyAuditEntry => line.type === 'request'); + if (!entries.length) { + console.log('No audit entries for this session.'); + return; + } + for (const entry of entries) { + console.log(formatAuditEntry(entry)); + } +} + export const commandFn: TypedGunshiCommandFn = async (ctx) => { const action = getAction(ctx); @@ -688,6 +763,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = return await envAction(ctx); case 'status': return await statusAction(ctx); + case 'audit': + return await auditAction(ctx); case 'refresh': return await refreshAction(ctx); case 'stop': @@ -695,7 +772,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = default: throw new CliExitError( `Unknown proxy action "${action}".`, - { suggestion: 'Use one of: run, start, env, status, refresh, stop' }, + { suggestion: 'Use one of: run, start, env, status, audit, refresh, stop' }, ); } }; diff --git a/packages/varlock/src/proxy/audit.test.ts b/packages/varlock/src/proxy/audit.test.ts new file mode 100644 index 000000000..f85a15f74 --- /dev/null +++ b/packages/varlock/src/proxy/audit.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; + +import http from 'node:http'; +import { URL } from 'node:url'; + +import { + createProxyAuditLog, + getProxyAuditFilePath, + hashRequest, + readProxyAuditLines, + type ProxyActivity, + type ProxyAuditEntry, +} from './audit'; +import { startLocalProxyRuntime } from './runtime-proxy'; + +// Redirect the audit dir into a throwaway XDG_CONFIG_HOME so we never touch the +// real user config dir. getProxyAuditDir() resolves lazily, so this takes effect. +let tmpDir: string; +let prevXdg: string | undefined; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'varlock-audit-test-')); + prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmpDir; +}); + +afterEach(async () => { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + await rm(tmpDir, { recursive: true, force: true }); +}); + +const REAL_SECRET = 'sk-stub-REALSECRETVALUE'; +const PLACEHOLDER = 'sk-stub-PLACEHOLDER'; + +function allowActivity(overrides: Partial = {}): ProxyActivity { + return { + host: 'api.example.com', + method: 'POST', + path: '/v1/charges', + url: '/v1/charges?expand=true', + matched: true, + blocked: false, + decision: 'allow', + injectedKeys: ['API_KEY'], + ruleId: 'api.example.com POST /v1/**', + ...overrides, + }; +} + +describe('proxy audit log', () => { + test('writes a session-start header then one entry per recorded request', async () => { + const uuid = 'header-and-entries'; + const log = createProxyAuditLog(uuid, { + ts: '2026-06-12T00:00:00.000Z', + id: 'ab123', + uuid, + cwd: '/tmp/project', + egressMode: 'strict', + command: ['claude'], + }); + log.record(allowActivity()); + log.record(allowActivity({ decision: 'deny', blocked: true, injectedKeys: undefined })); + await log.flush(); + + const lines = await readProxyAuditLines(uuid); + expect(lines).toHaveLength(3); + expect(lines[0]).toMatchObject({ + type: 'session-start', id: 'ab123', egressMode: 'strict', command: ['claude'], + }); + expect(lines[1]).toMatchObject({ + type: 'request', decision: 'allow', injected: true, injectedKeys: ['API_KEY'], + }); + expect(lines[2]).toMatchObject({ type: 'request', decision: 'deny', injected: false }); + expect((lines[2] as ProxyAuditEntry).injectedKeys).toBeUndefined(); + }); + + test('never persists a secret value, even when injectedKeys are present', async () => { + const uuid = 'no-secrets'; + const log = createProxyAuditLog(uuid); + // A real secret would only ever be in the wire request, never in the activity + // we record — but assert the on-disk bytes contain neither the real value nor + // even the placeholder path/query beyond what we explicitly logged. + log.record(allowActivity({ + path: `/v1/${PLACEHOLDER}`, + url: `/v1/${PLACEHOLDER}?token=${PLACEHOLDER}`, + })); + await log.flush(); + + const raw = await readFile(getProxyAuditFilePath(uuid), 'utf8'); + expect(raw).not.toContain(REAL_SECRET); + // The entry records the key name, not a value. + expect(raw).toContain('API_KEY'); + // Path is recorded readably (placeholders are safe); the query is only folded + // into the fingerprint hash, never stored verbatim. + const entry = (await readProxyAuditLines(uuid))[0] as ProxyAuditEntry; + expect(entry.path).toBe(`/v1/${PLACEHOLDER}`); + expect(entry.requestHash).toBe(hashRequest('POST', 'api.example.com', `/v1/${PLACEHOLDER}?token=${PLACEHOLDER}`)); + expect(JSON.stringify(entry)).not.toContain('token='); + }); + + test('appends across multiple log instances for the same session (append-only)', async () => { + const uuid = 'append-only'; + const first = createProxyAuditLog(uuid); + first.record(allowActivity()); + await first.flush(); + + const second = createProxyAuditLog(uuid); + second.record(allowActivity({ method: 'GET', path: '/v1/list' })); + await second.flush(); + + const entries = (await readProxyAuditLines(uuid)).filter((l): l is ProxyAuditEntry => l.type === 'request'); + expect(entries.map((e) => e.method)).toEqual(['POST', 'GET']); + }); + + test('hashRequest is deterministic and order-sensitive', () => { + const a = hashRequest('GET', 'api.example.com', '/x'); + expect(hashRequest('GET', 'api.example.com', '/x')).toBe(a); + expect(hashRequest('POST', 'api.example.com', '/x')).not.toBe(a); + }); + + test('readProxyAuditLines returns empty for an unknown session', async () => { + expect(await readProxyAuditLines('does-not-exist')).toEqual([]); + }); + + test('record never throws even if the write fails (fail-safe)', async () => { + // Point XDG at a path that can't be created (a file, not a dir) so mkdir fails. + process.env.XDG_CONFIG_HOME = join(tmpDir, 'not-a-dir', '\0invalid'); + const log = createProxyAuditLog('fail-safe'); + expect(() => log.record(allowActivity())).not.toThrow(); + await expect(log.flush()).resolves.toBeUndefined(); + }); + + // End-to-end seam: a real proxy runtime's onActivity, wired to a real audit + // log (exactly as the proxy command composes them), persists a request entry. + test('runtime activity flows through onActivity into an on-disk audit entry', async () => { + const upstream = http.createServer((_req, res) => res.end('ok')); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const uuid = 'runtime-seam'; + const log = createProxyAuditLog(uuid, { + ts: '2026-06-12T00:00:00.000Z', id: 'seam1', uuid, cwd: '/tmp', egressMode: 'permissive', + }); + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + egressMode: 'permissive', + onActivity: (activity) => log.record(activity), + }); + + const proxy = new URL(runtime.env.HTTP_PROXY!); + await new Promise((resolve, reject) => { + const req = http.request({ + host: proxy.hostname, + port: Number(proxy.port), + method: 'GET', + path: `http://127.0.0.1:${addr.port}/audited/path`, + }, (res) => { + res.on('data', () => undefined); + res.on('end', () => resolve()); + }); + req.on('error', reject); + req.end(); + }); + + await runtime.stop(); + await log.flush(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + + const lines = await readProxyAuditLines(uuid); + expect(lines[0]).toMatchObject({ type: 'session-start', id: 'seam1' }); + const entry = lines.find((l): l is ProxyAuditEntry => l.type === 'request'); + expect(entry).toMatchObject({ + decision: 'allow', host: '127.0.0.1', method: 'GET', path: '/audited/path', + }); + }); +}); diff --git a/packages/varlock/src/proxy/audit.ts b/packages/varlock/src/proxy/audit.ts new file mode 100644 index 000000000..50e572950 --- /dev/null +++ b/packages/varlock/src/proxy/audit.ts @@ -0,0 +1,156 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getUserVarlockDir } from '../lib/user-config-dir'; + +/** The security decision the proxy reached for a single request. */ +export type ProxyAuditDecision = | 'allow' // forwarded upstream (a secret may or may not have been injected) + | 'deny' // matched a `block` rule — never reached upstream + | 'blocked-egress' // strict egress mode rejected a non-allowlisted host + | 'blocked-cleartext'; // refused to inject a secret into a non-TLS connection + +/** + * Structured per-request activity emitted by the proxy runtime. It carries + * everything the audit log needs but **never** a secret value: `path`/`url` are + * the child's *placeholder-form* request (injection happens after this is + * emitted), and `injectedKeys` are item keys (names), not values. + */ +export type ProxyActivity = { + /** Whether the host matched a configured `@proxy` rule. */ + matched: boolean; + /** Whether the request was blocked (egress, policy, or cleartext guard). */ + blocked: boolean; + host: string; + method: string; + /** Path only, no query string, in placeholder form. */ + path: string; + /** Full path + query in placeholder form — used only to compute the fingerprint hash. */ + url?: string; + decision: ProxyAuditDecision; + /** Stable descriptor of the matched rule (see `describeRule`), if any. */ + ruleId?: string; + /** Keys (names, never values) of the managed items actually injected into this request. */ + injectedKeys?: Array; +}; + +/** First line of every audit file — makes the file self-describing after the session record is gone. */ +export type ProxyAuditHeader = { + type: 'session-start'; + ts: string; + id: string; + uuid: string; + cwd: string; + egressMode: string; + command?: Array; +}; + +/** One persisted request line in the audit log. Holds no secret values or raw bodies. */ +export type ProxyAuditEntry = { + type: 'request'; + ts: string; + host: string; + method: string; + /** Path only, no query, placeholder form. */ + path: string; + /** sha256 of `${method} ${host} ${url}` (placeholder form) — a stable request fingerprint. */ + requestHash: string; + decision: ProxyAuditDecision; + matched: boolean; + injected: boolean; + injectedKeys?: Array; + ruleId?: string; +}; + +export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry; + +// Resolved lazily (not a module-load const) so it honors the active +// XDG_CONFIG_HOME / legacy-dir resolution at call time. +export function getProxyAuditDir(): string { + return join(getUserVarlockDir(), 'proxy', 'audit'); +} + +export function getProxyAuditFilePath(uuid: string): string { + return join(getProxyAuditDir(), `${uuid}.jsonl`); +} + +/** Stable request fingerprint. Inputs are placeholder-form, so this hashes no secret. */ +export function hashRequest(method: string, host: string, url: string): string { + return createHash('sha256').update(`${method} ${host} ${url}`).digest('hex'); +} + +function activityToEntry(activity: ProxyActivity, ts: string): ProxyAuditEntry { + const injectedKeys = activity.injectedKeys?.length ? activity.injectedKeys : undefined; + return { + type: 'request', + ts, + host: activity.host, + method: activity.method, + path: activity.path, + requestHash: hashRequest(activity.method, activity.host, activity.url ?? activity.path), + decision: activity.decision, + matched: activity.matched, + injected: !!injectedKeys, + ...(injectedKeys ? { injectedKeys } : {}), + ...(activity.ruleId ? { ruleId: activity.ruleId } : {}), + }; +} + +/** + * Append-only JSONL audit log for one proxy session (Invariant #7). Writes are + * serialized through a promise chain so concurrent requests can't interleave + * partial lines, and every failure is swallowed and disables further writes — + * audit logging must never crash or stall the proxy hot path. Holds no secrets. + */ +export function createProxyAuditLog(uuid: string, header?: Omit) { + const filePath = getProxyAuditFilePath(uuid); + let chain: Promise = Promise.resolve(); + let disabled = false; + + const enqueue = (line: ProxyAuditLine) => { + chain = chain.then(async () => { + if (disabled) return; + try { + await mkdir(getProxyAuditDir(), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(line)}\n`, { mode: 0o600 }); + } catch { + disabled = true; // never let audit I/O break the proxy + } + }); + }; + + if (header) enqueue({ type: 'session-start', ...header }); + + return { + filePath, + /** Record a request's decision. Returns immediately; the write is queued. */ + record(activity: ProxyActivity) { + enqueue(activityToEntry(activity, new Date().toISOString())); + }, + /** Resolve once all queued writes have flushed to disk. */ + async flush() { + await chain; + }, + }; +} + +export type ProxyAuditLog = ReturnType; + +/** Read and parse a session's audit log, skipping any malformed lines. Empty if none exists. */ +export async function readProxyAuditLines(uuid: string): Promise> { + const filePath = getProxyAuditFilePath(uuid); + if (!existsSync(filePath)) return []; + const raw = await readFile(filePath, 'utf8'); + const lines: Array = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + lines.push(JSON.parse(trimmed) as ProxyAuditLine); + } catch { + // skip a torn/partial trailing line rather than fail the whole read + } + } + return lines; +} diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts index 615b970ef..f6e837a7a 100644 --- a/packages/varlock/src/proxy/policy.ts +++ b/packages/varlock/src/proxy/policy.ts @@ -65,6 +65,19 @@ function ruleSpecificity(rule: ProxyRule): number { return (rule.path !== undefined ? 2 : 0) + (rule.method !== undefined ? 1 : 0); } +/** + * A stable, human-readable identifier for a rule, derived from its match + * constraints (rules have no explicit id). Used as the audit log's `ruleId` so + * a logged decision can be traced back to the rule that produced it. + */ +export function describeRule(rule: ProxyRule): string { + const parts = [rule.domain.join('|')]; + if (rule.method !== undefined) parts.push(rule.method.toUpperCase()); + if (rule.path !== undefined) parts.push(rule.path); + if (rule.block) parts.push('block'); + return parts.join(' '); +} + /** * Evaluate the policy for a request. Precedence: * 1. any matching `block` rule wins → deny diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index f0e9aa14a..cd5b0a26b 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -119,10 +119,12 @@ describe('proxy HTTPS MITM (end-to-end)', () => { res.end(JSON.stringify({ ok: true })); }); + const activities: Array = []; const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', + onActivity: (a) => activities.push(a), }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -152,6 +154,13 @@ describe('proxy HTTPS MITM (end-to-end)', () => { expect(upstreamAuthHeader).toBe('Bearer sk-stub-REALKEY'); expect(upstreamAuthHeader).not.toContain('PLACEHOLDER'); + // The audit activity records the injected item by KEY, with the real + // decision, and never carries the real (or placeholder) secret value. + const allow = activities.find((a) => a.decision === 'allow'); + expect(allow).toBeDefined(); + expect(allow).toMatchObject({ host: UPSTREAM_HOST, method: 'GET', injectedKeys: ['API_KEY'] }); + expect(JSON.stringify(activities)).not.toContain('sk-stub-REALKEY'); + tlsSocket.destroy(); await runtime.stop(); await upstream.close(); diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index f59117d6a..79813e43b 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import http from 'node:http'; import { URL } from 'node:url'; +import type { ProxyActivity } from './audit'; import { startLocalProxyRuntime } from './runtime-proxy'; async function requestViaProxy(proxyUrl: string, targetUrl: string, headers?: Record) { @@ -143,6 +144,128 @@ describe('startLocalProxyRuntime', () => { }); }); + test('emits a blocked-egress activity in strict mode (no secrets in the activity)', async () => { + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [], + egressMode: 'strict', + onActivity: (a) => activities.push(a), + }); + + await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://example.com/some/path'); + + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + decision: 'blocked-egress', host: 'example.com', method: 'GET', path: '/some/path', matched: false, blocked: true, + }); + expect(activities[0]!.injectedKeys).toBeUndefined(); + + await runtime.stop(); + }); + + test('emits a deny activity (block rule) that never reaches upstream', async () => { + let upstreamHit = false; + const upstream = http.createServer((_req, res) => { + upstreamHit = true; + res.end('ok'); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [ + { + source: 'attached', domain: ['127.0.0.1'], itemKeys: [], block: true, + }, + ], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + }); + + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/charge`); + expect(response.statusCode).toBe(403); + expect(upstreamHit).toBe(false); + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + decision: 'deny', host: '127.0.0.1', path: '/charge', matched: true, blocked: true, + }); + expect(activities[0]!.ruleId).toContain('block'); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + + test('emits a single blocked-cleartext activity (not allow-then-block) and no secret', async () => { + const upstream = http.createServer((_req, res) => res.end('ok')); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'PH_placeholder', realValue: 'sk-REAL-secret' }], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + }); + + await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/`, { + authorization: 'Bearer PH_placeholder', + }); + + expect(activities).toHaveLength(1); + expect(activities[0]!.decision).toBe('blocked-cleartext'); + expect(JSON.stringify(activities)).not.toContain('sk-REAL-secret'); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + + test('emits an allow activity for a forwarded (non-injected) request', async () => { + const upstream = http.createServer((_req, res) => res.end('ok')); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + }); + + await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/list?page=2`); + + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + decision: 'allow', host: '127.0.0.1', path: '/list', matched: true, blocked: false, + }); + // path excludes the query; the full url is carried separately for the hash + expect(activities[0]!.path).toBe('/list'); + expect(activities[0]!.url).toBe('/list?page=2'); + expect(activities[0]!.injectedKeys).toBeUndefined(); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + test('streams text/event-stream responses through incrementally (no buffering)', async () => { const INTER_CHUNK_DELAY = 200; const upstream = http.createServer((_req, res) => { diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 16815f6d7..fab1b449f 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -11,9 +11,10 @@ import { StringDecoder } from 'node:string_decoder'; import tls from 'node:tls'; import { URL } from 'node:url'; +import type { ProxyActivity } from './audit'; import { createEphemeralCa, createHostCert } from './cert-authority'; import { - evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, + describeRule, evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, } from './policy'; import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from './types'; @@ -28,10 +29,7 @@ export type StartLocalProxyRuntimeInput = { managedItems: Array; rules: Array; egressMode: ProxyEgressMode; - onActivity?: (activity: { - matched: boolean; - blocked: boolean; - }) => void; + onActivity?: (activity: ProxyActivity) => void; }; type HostInfo = { host: string, port: number }; @@ -124,6 +122,20 @@ function replacePlaceholdersWithReal(value: string, managedItems: Array, hostItems: Array): Array { + const keys: Array = []; + for (const item of hostItems) { + if (!item.placeholder) continue; + if (parts.some((part) => part.includes(item.placeholder))) keys.push(item.key); + } + return keys; +} + function replaceRealWithPlaceholders(value: string, managedItems: Array): string { let next = value; const sortedByRealLength = [...managedItems] @@ -377,12 +389,18 @@ export async function startLocalProxyRuntime({ return; } + const method = req.method ?? 'GET'; + const rawUrl = req.url ?? '/'; + const pathOnly = rawUrl.split('?')[0] ?? '/'; + const baseActivity = { + host: hostInfo.host, method, path: pathOnly, url: rawUrl, + }; + const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { onActivity?.({ - matched: shouldRewrite, - blocked: true, + ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', }); res.statusCode = 403; res.end('Proxy egress blocked by strict mode'); @@ -390,13 +408,13 @@ export async function startLocalProxyRuntime({ } // Per-call policy (Invariant: static authorization). Evaluate host + method // + path against the rules; a matching `block` rule denies the request. - const facts: RequestFacts = { - host: hostInfo.host, - method: req.method ?? 'GET', - path: (req.url ?? '/').split('?')[0] ?? '/', - }; - if (shouldRewrite && evaluateProxyPolicy(facts, rules).verdict === 'deny') { - onActivity?.({ matched: true, blocked: true }); + const facts: RequestFacts = { host: hostInfo.host, method, path: pathOnly }; + const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; + const ruleId = policyDecision?.matchedRule ? { ruleId: describeRule(policyDecision.matchedRule) } : {}; + if (policyDecision?.verdict === 'deny') { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', + }); // Fail closed: the request is denied and never reaches upstream. Best-effort // 403, then tear down (short MITM-tunnel responses don't reliably flush). try { @@ -407,12 +425,20 @@ export async function startLocalProxyRuntime({ return; } + const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; + const body = await readBody(req); + const injectedKeys = shouldRewrite + ? detectInjectedKeys([rawUrl, JSON.stringify(req.headers), body.toString('utf8')], hostItems) + : []; onActivity?.({ + ...baseActivity, + ...ruleId, matched: shouldRewrite, blocked: false, + decision: 'allow', + ...(injectedKeys.length ? { injectedKeys } : {}), }); - const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; - const body = await readBody(req); + const rewrittenBody = shouldRewrite ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') : body; @@ -426,8 +452,8 @@ export async function startLocalProxyRuntime({ delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(req.url ?? '/', hostItems) - : (req.url ?? '/'); + ? buildPathnameAndQuery(rawUrl, hostItems) + : rawUrl; if (rewrittenBody.byteLength !== body.byteLength) { upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); @@ -519,46 +545,62 @@ export async function startLocalProxyRuntime({ return; } + const method = clientReq.method ?? 'GET'; + const pathOnly = destination.pathname; + const url = `${destination.pathname}${destination.search}`; + const baseActivity = { + host: destination.hostname, method, path: pathOnly, url, + }; + const shouldRewrite = hostMatchesProxyRules(destination.hostname, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { onActivity?.({ - matched: shouldRewrite, - blocked: true, + ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', }); clientRes.statusCode = 403; clientRes.end('Proxy egress blocked by strict mode'); return; } - const facts: RequestFacts = { - host: destination.hostname, - method: clientReq.method ?? 'GET', - path: destination.pathname, - }; - if (shouldRewrite && evaluateProxyPolicy(facts, rules).verdict === 'deny') { - onActivity?.({ matched: true, blocked: true }); + const facts: RequestFacts = { host: destination.hostname, method, path: pathOnly }; + const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; + const ruleId = policyDecision?.matchedRule ? { ruleId: describeRule(policyDecision.matchedRule) } : {}; + if (policyDecision?.verdict === 'deny') { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', + }); clientRes.statusCode = 403; clientRes.end('Blocked by proxy policy'); return; } - onActivity?.({ - matched: shouldRewrite, - blocked: false, - }); const isHttps = destination.protocol === 'https:'; const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) // connection — no cert means no verifiable identity. Fail closed. if (hostItems.length > 0 && !isHttps) { - onActivity?.({ matched: true, blocked: true }); + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'blocked-cleartext', + }); clientRes.statusCode = 403; clientRes.end('Refusing to inject secrets into a cleartext (non-TLS) connection'); return; } const body = await readBody(clientReq); + const injectedKeys = shouldRewrite + ? detectInjectedKeys([url, JSON.stringify(clientReq.headers), body.toString('utf8')], hostItems) + : []; + onActivity?.({ + ...baseActivity, + ...ruleId, + matched: shouldRewrite, + blocked: false, + decision: 'allow', + ...(injectedKeys.length ? { injectedKeys } : {}), + }); + const rewrittenBody = shouldRewrite ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') : body; @@ -615,9 +657,16 @@ export async function startLocalProxyRuntime({ const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { + // CONNECT only exposes host:port; the per-request audit entry (method/path) + // comes later from the MITM handler for allowed hosts. Here we record the + // host-level egress denial. onActivity?.({ + host: hostInfo.host, + method: 'CONNECT', + path: '/', matched: shouldRewrite, blocked: true, + decision: 'blocked-egress', }); const blockedBody = 'Proxy egress blocked by strict mode'; clientSocket.write( From b8160a337c20b15a898094b76897c9ec7330309e Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 22:42:48 -0700 Subject: [PATCH 08/64] refactor(varlock): remove dead proxy config (pin/sign/transform) These ProxyRule fields were parsed but never used: pin (cert pinning) is deferred for delegated external identity verification, and sign/transform belong to a future @proxyResign decorator, not routing rules. Removing avoids implying features that don't exist. Invariant #1 (PKI + SAN identity check) is unchanged. --- .bumpy/proxy-phase1-guardrails.md | 2 +- .../varlock/src/env-graph/lib/env-graph.ts | 4 -- packages/varlock/src/proxy/runtime-proxy.ts | 49 +++++-------------- packages/varlock/src/proxy/types.ts | 9 ---- 4 files changed, 12 insertions(+), 52 deletions(-) diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md index 66700f4ce..53cc59a59 100644 --- a/.bumpy/proxy-phase1-guardrails.md +++ b/.bumpy/proxy-phase1-guardrails.md @@ -4,7 +4,7 @@ varlock: patch Add proxy guardrails and strict egress enforcement for run --proxy. -Verified-upstream-identity binding (Invariant #1): secrets are injected only onto upstream TLS connections that validate against the public PKI AND whose cert identity matches the rule-targeted host (with optional per-rule cert pinning). A DNS-poisoned / rebound host can't present a valid cert for the target, so it causes a failed connection, never a leaked secret. Cleartext guard (Invariant #2): refuse to inject a secret into a non-TLS (http://) connection; fail closed. Upstream-error path now fails closed (tears down the client connection) rather than half-delivering. +Verified-upstream-identity binding (Invariant #1): secrets are injected only onto upstream TLS connections that validate against the public PKI AND whose cert identity matches the rule-targeted host. A DNS-poisoned / rebound host can't present a valid cert for the target, so it causes a failed connection, never a leaked secret. Cleartext guard (Invariant #2): refuse to inject a secret into a non-TLS (http://) connection; fail closed. Upstream-error path now fails closed (tears down the client connection) rather than half-delivering. Response scrubbing (Invariant #6): real values reflected in responses are replaced back to placeholders — now including streamed (SSE/chunked) text responses, scrubbed chunk-by-chunk without buffering (so a secret echoed in a stream never reaches the child while streaming still works). Bounded responses gain a post-scrub fail-safe: if a real value somehow survives redaction, the response is withheld rather than leaked. Compressed/binary bodies still pass through unscanned (documented residual). diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 8b6c9af1b..6dc8ee6e0 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -914,8 +914,6 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...(_.isString(resolved?.obj?.sign) ? { sign: resolved.obj.sign } : {}), - ...(_.isString(resolved?.obj?.transform) ? { transform: resolved.obj.transform } : {}), }); } @@ -936,8 +934,6 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...(_.isString(resolved?.obj?.sign) ? { sign: resolved.obj.sign } : {}), - ...(_.isString(resolved?.obj?.transform) ? { transform: resolved.obj.transform } : {}), }); } } diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index fab1b449f..017eb1c71 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -62,53 +62,26 @@ function hostMatchesProxyRules(host: string, rules: Array): boolean { return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); } -function normalizeFingerprint(fingerprint: string): string { - return fingerprint.replace(/:/g, '').toLowerCase(); -} - -function getCertPinsForHost(host: string, rules: Array): Set { - const pins = new Set(); - for (const rule of rules) { - if (!rule.pin?.length) continue; - if (rule.domain.some((d) => domainMatches(d, host))) { - for (const pin of rule.pin) pins.add(normalizeFingerprint(pin)); - } - } - return pins; -} - /** - * Invariant #1 (+ #4): bind secret injection to the *verified upstream TLS - * identity*, not the requested name. Returns TLS options for the secret-bearing - * upstream request that (a) require validation against the public PKI - * (`rejectUnauthorized`), (b) confirm the cert identity matches the host we - * dialed (which is the rule-matched host), and (c) enforce any per-rule cert - * pinning. On any mismatch the handshake fails, so the already-substituted - * request is never transmitted — a poisoned DNS/Host name causes a failed - * connection, never a leaked secret. + * Invariant #1: bind secret injection to the *verified upstream TLS identity*, + * not the requested name. Returns TLS options for the secret-bearing upstream + * request that (a) require validation against the public PKI + * (`rejectUnauthorized`) and (b) confirm the cert identity matches the host we + * dialed (which is the rule-matched host). On any mismatch the handshake fails, + * so the already-substituted request is never transmitted — a poisoned + * DNS/Host name causes a failed connection, never a leaked secret. */ -function buildVerifiedUpstreamOptions(host: string, rules: Array): { +function buildVerifiedUpstreamOptions(): { rejectUnauthorized: true; checkServerIdentity: (servername: string, cert: tls.PeerCertificate) => Error | undefined; } { - const pins = getCertPinsForHost(host, rules); // Deliberately do NOT set `servername` — Node derives it from the request // hostname (SNI for DNS names; omitted for IP literals, where setting it // throws) and still passes the host to checkServerIdentity, so identity is // verified against the host we dialed (= the rule-matched host) either way. return { rejectUnauthorized: true, - checkServerIdentity: (servername, cert) => { - const identityError = tls.checkServerIdentity(servername, cert); - if (identityError) return identityError; - if (pins.size) { - const actual = normalizeFingerprint(String(cert.fingerprint256 ?? '')); - if (!actual || !pins.has(actual)) { - return new Error(`Upstream certificate for ${servername} did not match a pinned fingerprint`); - } - } - return undefined; - }, + checkServerIdentity: (servername, cert) => tls.checkServerIdentity(servername, cert), }; } @@ -466,7 +439,7 @@ export async function startLocalProxyRuntime({ method: req.method, path: rewrittenPath, headers: upstreamHeaders, - ...buildVerifiedUpstreamOptions(hostInfo.host, rules), + ...buildVerifiedUpstreamOptions(), }, (upstreamRes) => { forwardUpstreamResponseWithRedaction( upstreamRes, @@ -629,7 +602,7 @@ export async function startLocalProxyRuntime({ method: clientReq.method, path: rewrittenPath, headers: upstreamHeaders, - ...(isHttps ? buildVerifiedUpstreamOptions(destination.hostname, rules) : {}), + ...(isHttps ? buildVerifiedUpstreamOptions() : {}), }, (upstreamRes) => { forwardUpstreamResponseWithRedaction( upstreamRes, diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index 8f8ebf8e5..00b1eb7c1 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -9,15 +9,6 @@ export type ProxyRule = { path?: string; method?: string; block?: boolean; - sign?: string; - transform?: string; - /** - * Optional per-rule certificate pinning (Invariant #4). Expected upstream - * cert SHA-256 fingerprints; if set, the upstream cert must match one of them - * in addition to validating against the public PKI. Closes the residual hole - * where any mis-issued-but-publicly-trusted cert would otherwise pass. - */ - pin?: Array; }; export type ProxyManagedItem = { From dd72af49074d1a8b5565dde28e6c798906190b12 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 22:58:04 -0700 Subject: [PATCH 09/64] feat(varlock): proxy require-approval verdict (Invariant #8) @proxy(approve=true) holds a request for an out-of-band, request-bound approval (method+host+path+body-hash+nonce+expiry) before forwarding. Precedence block>require-approval>allow. MVP approver = TTY prompt under proxy start; fails closed (deny) on timeout/no-TTY/no-approver. Outcomes audited as approval-granted/denied. --- .bumpy/proxy-require-approval.md | 7 + .../varlock/src/cli/commands/proxy.command.ts | 14 ++ .../varlock/src/env-graph/lib/env-graph.ts | 2 + packages/varlock/src/proxy/approval.test.ts | 115 ++++++++++++++ packages/varlock/src/proxy/approval.ts | 143 ++++++++++++++++++ packages/varlock/src/proxy/audit.ts | 4 +- packages/varlock/src/proxy/policy.test.ts | 35 +++++ packages/varlock/src/proxy/policy.ts | 34 +++-- .../varlock/src/proxy/runtime-proxy.test.ts | 84 ++++++++++ packages/varlock/src/proxy/runtime-proxy.ts | 88 ++++++++++- packages/varlock/src/proxy/types.ts | 2 + 11 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 .bumpy/proxy-require-approval.md create mode 100644 packages/varlock/src/proxy/approval.test.ts create mode 100644 packages/varlock/src/proxy/approval.ts diff --git a/.bumpy/proxy-require-approval.md b/.bumpy/proxy-require-approval.md new file mode 100644 index 000000000..80db079f4 --- /dev/null +++ b/.bumpy/proxy-require-approval.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Add a `require-approval` verdict to the proxy (Invariant #8). + +Mark a rule `@proxy(domain="...", approve=true)` and matching requests are held for an out-of-band, request-bound approval before being forwarded. The approval commits to the exact request — method + verified host + path + body hash + nonce + expiry — so a future signed phone-approval relay drops in unchanged. Precedence is block > require-approval > allow (most restrictive wins). The MVP approver is a terminal prompt under `varlock proxy start` (where the proxy owns the terminal; under `varlock proxy run` the child owns stdio, so approval-required requests fail closed). Everything fails closed — denied, timed-out, or unanswerable approvals never reach upstream — and each outcome is recorded in the audit log as `approval-granted` / `approval-denied`. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index ae7dd1466..1653a3b11 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -10,6 +10,11 @@ import { type ProxyAuditEntry, type ProxyAuditLog, } from '../../proxy/audit'; +import { + createAutoDenyApprovalProvider, + createTtyApprovalProvider, + type ApprovalProvider, +} from '../../proxy/approval'; import { cleanupStaleProxySessions, createProxySessionRecord, @@ -288,6 +293,7 @@ async function createRuntimeAndSession(opts: { policy: PreparedProxyPolicy; entryPaths?: Array; command?: Array; + approvalProvider: ApprovalProvider; }): Promise<{ runtime: Awaited>; session: ProxySessionRecord; @@ -311,6 +317,7 @@ async function createRuntimeAndSession(opts: { managedItems: opts.policy.proxyManagedItems, rules: opts.policy.proxyRules, egressMode: opts.policy.egressMode, + approvalProvider: opts.approvalProvider, onActivity: (activity) => { statsWriter.onActivity(activity); auditLog.record(activity); @@ -406,6 +413,10 @@ async function runAction(ctx: any) { policy, entryPaths: ctx.values.path, command: commandToRunAsArgs, + // The child owns this terminal's stdio, so we can't safely prompt here — + // require-approval requests fail closed. Use `proxy start` for interactive + // approval (the proxy owns the terminal there). + approvalProvider: createAutoDenyApprovalProvider(), }); console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); @@ -539,6 +550,9 @@ async function startAction(ctx: any) { } = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, + // The proxy owns this terminal (the agent runs elsewhere and routes through + // it), so require-approval requests can prompt here. + approvalProvider: createTtyApprovalProvider(), }); console.log(`Started proxy session ${session.id} (${session.uuid})`); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 6dc8ee6e0..fa37e7c9e 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -914,6 +914,7 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), + ...(_.isBoolean(resolved?.obj?.approve) ? { approve: resolved.obj.approve } : {}), }); } @@ -934,6 +935,7 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), + ...(_.isBoolean(resolved?.obj?.approve) ? { approve: resolved.obj.approve } : {}), }); } } diff --git a/packages/varlock/src/proxy/approval.test.ts b/packages/varlock/src/proxy/approval.test.ts new file mode 100644 index 000000000..2b0ffa98e --- /dev/null +++ b/packages/varlock/src/proxy/approval.test.ts @@ -0,0 +1,115 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, test } from 'vitest'; + +import { + createApprovalRequest, + createAutoDenyApprovalProvider, + createTtyApprovalProvider, + hashBody, + isApprovalValid, + type ApprovalRequest, +} from './approval'; + +type ReqInput = Parameters[0]; +function req(overrides: Partial = {}): ApprovalRequest { + return createApprovalRequest({ + method: 'POST', + host: 'api.stripe.com', + path: '/v1/refunds', + body: '{"amount":100}', + ruleId: 'api.stripe.com /v1/refunds approve', + injectedKeys: ['STRIPE_KEY'], + ...overrides, + }); +} + +describe('createApprovalRequest', () => { + test('binds to the request: body hash, nonce, expiry, and metadata', () => { + const r = req(); + expect(r.bodyHash).toBe(hashBody('{"amount":100}')); + expect(r.nonce).toMatch(/^[0-9a-f]{32}$/); + expect(r.expiresAt).toBeGreaterThan(0); + expect(r.injectedKeys).toEqual(['STRIPE_KEY']); + expect(r.ruleId).toContain('approve'); + }); +}); + +describe('isApprovalValid', () => { + test('honors only an approval that echoes the nonce and is unexpired', () => { + const r = req(); + expect(isApprovalValid(r, { approved: true, nonce: r.nonce })).toBe(true); + expect(isApprovalValid(r, { approved: false, nonce: r.nonce })).toBe(false); // denied + expect(isApprovalValid(r, { approved: true, nonce: 'wrong' })).toBe(false); // nonce mismatch + }); + + test('rejects an expired request even if approved with the right nonce', () => { + const expired = req({ ttlMs: -1000 }); + expect(isApprovalValid(expired, { approved: true, nonce: expired.nonce })).toBe(false); + }); +}); + +describe('createAutoDenyApprovalProvider', () => { + test('always denies, echoing the nonce', async () => { + const r = req(); + const decision = await createAutoDenyApprovalProvider().requestApproval(r); + expect(decision).toMatchObject({ approved: false, nonce: r.nonce }); + expect(isApprovalValid(r, decision)).toBe(false); + }); +}); + +describe('createTtyApprovalProvider', () => { + function ttyInput() { + const input = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + return input; + } + + test('approves on "y" and the decision authorizes the request', async () => { + const input = ttyInput(); + const output = new PassThrough(); + const provider = createTtyApprovalProvider({ input, output }); + const r = req(); + const pending = provider.requestApproval(r); + input.write('y\n'); + const decision = await pending; + expect(decision.approved).toBe(true); + expect(isApprovalValid(r, decision)).toBe(true); + }); + + test('denies on "n" (and anything that is not yes)', async () => { + for (const answer of ['n\n', '\n', 'maybe\n']) { + const input = ttyInput(); + const provider = createTtyApprovalProvider({ input, output: new PassThrough() }); + const pending = provider.requestApproval(req()); + input.write(answer); + expect((await pending).approved).toBe(false); + } + }); + + test('shows the request being approved in the prompt (no secret value)', async () => { + const input = ttyInput(); + const output = new PassThrough(); + let prompt = ''; + output.on('data', (c: Buffer) => { + prompt += c.toString('utf8'); + }); + const provider = createTtyApprovalProvider({ input, output }); + const pending = provider.requestApproval(req()); + input.write('y\n'); + await pending; + expect(prompt).toContain('POST https://api.stripe.com/v1/refunds'); + expect(prompt).toContain('STRIPE_KEY'); // key name shown + }); + + test('fails closed when there is no TTY', async () => { + const provider = createTtyApprovalProvider({ input: new PassThrough(), output: new PassThrough() }); + expect((await provider.requestApproval(req())).approved).toBe(false); + }); + + test('fails closed on timeout', async () => { + const input = ttyInput(); + const provider = createTtyApprovalProvider({ input, output: new PassThrough(), timeoutMs: 30 }); + // never write input → the timeout fires and denies + expect((await provider.requestApproval(req())).approved).toBe(false); + }); +}); diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts new file mode 100644 index 000000000..1d7e9cb76 --- /dev/null +++ b/packages/varlock/src/proxy/approval.ts @@ -0,0 +1,143 @@ +import { createHash, randomBytes } from 'node:crypto'; +import readline from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; + +/** + * A request-bound approval request (Invariant #8). It commits to the EXACT + * request — method + verified host + path + body hash + a nonce + an expiry — + * so that when this local stub is replaced by the signed phone-approval relay, + * an approval can only authorize the specific request it was shown, never a + * substituted one. The runtime honors a decision only if it echoes this + * request's `nonce` and arrives before `expiresAt`. + */ +export type ApprovalRequest = { + method: string; + /** Verified upstream host (Invariant #1), not the requested name. */ + host: string; + /** Path only, no query, placeholder form. */ + path: string; + /** sha256 of the final request body (no secret bytes — body is placeholder-form here). */ + bodyHash: string; + /** Unique per request; the decision must echo it. */ + nonce: string; + /** Epoch ms; the provider must decide before this. */ + expiresAt: number; + ruleId?: string; + /** Secret keys (names, never values) that WOULD be injected if approved. */ + injectedKeys?: Array; +}; + +export type ApprovalDecision = { + approved: boolean; + /** Echoes the request nonce the approver acted on; must match to be honored. */ + nonce: string; + reason?: string; +}; + +export interface ApprovalProvider { + /** + * Resolve with a decision for the given request. Implementations must fail + * closed — return `approved: false` (or reject) on timeout/error/unavailable. + */ + requestApproval(req: ApprovalRequest): Promise; +} + +export function hashBody(body: Buffer | string): string { + return createHash('sha256').update(body).digest('hex'); +} + +const DEFAULT_TTL_MS = 60_000; + +export function createApprovalRequest(input: { + method: string; + host: string; + path: string; + body: Buffer | string; + ttlMs?: number; + ruleId?: string; + injectedKeys?: Array; +}): ApprovalRequest { + return { + method: input.method, + host: input.host, + path: input.path, + bodyHash: hashBody(input.body), + nonce: randomBytes(16).toString('hex'), + expiresAt: Date.now() + (input.ttlMs ?? DEFAULT_TTL_MS), + ...(input.ruleId ? { ruleId: input.ruleId } : {}), + ...(input.injectedKeys?.length ? { injectedKeys: input.injectedKeys } : {}), + }; +} + +/** + * Whether a decision actually authorizes a request: it must approve, echo the + * request's nonce, and arrive before expiry. Centralizes the request-binding + * check so every provider (local stub today, phone relay later) is held to it. + */ +export function isApprovalValid(req: ApprovalRequest, decision: ApprovalDecision): boolean { + return decision.approved && decision.nonce === req.nonce && Date.now() < req.expiresAt; +} + +/** Always denies — the safe default when no interactive approver is available. */ +export function createAutoDenyApprovalProvider(): ApprovalProvider { + return { + async requestApproval(req) { + return { approved: false, nonce: req.nonce, reason: 'no approver available (auto-deny)' }; + }, + }; +} + +/** + * Prompts for approval on the proxy process's controlling terminal. Suited to + * `varlock proxy start`, where the agent runs elsewhere and the proxy owns the + * TTY. Fails closed: denies on a non-TTY, timeout, EOF, or any answer other than + * an explicit yes. + */ +export function createTtyApprovalProvider(opts?: { + input?: Readable & { isTTY?: boolean }; + output?: Writable; + timeoutMs?: number; +}): ApprovalProvider { + const input = opts?.input ?? process.stdin; + const output = opts?.output ?? process.stderr; + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TTL_MS; + + return { + async requestApproval(req) { + if (!input.isTTY) { + return { approved: false, nonce: req.nonce, reason: 'no interactive terminal to prompt for approval' }; + } + + const inj = req.injectedKeys?.length ? ` injecting [${req.injectedKeys.join(', ')}]` : ''; + const prompt = '\n🔐 varlock proxy — approval required\n' + + ` ${req.method} https://${req.host}${req.path}${inj}\n${ + req.ruleId ? ` rule: ${req.ruleId}\n` : '' + } Approve this request? [y/N] `; + + const rl = readline.createInterface({ input, output }); + const answer = await new Promise((resolve) => { + const timer = setTimeout(() => { + output.write('\n (timed out — denied)\n'); + resolve(''); + }, timeoutMs); + timer.unref?.(); + rl.question(prompt, (a) => { + clearTimeout(timer); + resolve(a); + }); + rl.on('close', () => { + clearTimeout(timer); + resolve(''); + }); + }); + rl.close(); + + const approved = /^y(es)?$/i.test(answer.trim()); + return { + approved, + nonce: req.nonce, + reason: approved ? 'approved at terminal' : 'denied at terminal', + }; + }, + }; +} diff --git a/packages/varlock/src/proxy/audit.ts b/packages/varlock/src/proxy/audit.ts index 50e572950..41a794de7 100644 --- a/packages/varlock/src/proxy/audit.ts +++ b/packages/varlock/src/proxy/audit.ts @@ -9,7 +9,9 @@ import { getUserVarlockDir } from '../lib/user-config-dir'; export type ProxyAuditDecision = | 'allow' // forwarded upstream (a secret may or may not have been injected) | 'deny' // matched a `block` rule — never reached upstream | 'blocked-egress' // strict egress mode rejected a non-allowlisted host - | 'blocked-cleartext'; // refused to inject a secret into a non-TLS connection + | 'blocked-cleartext' // refused to inject a secret into a non-TLS connection + | 'approval-granted' // require-approval rule matched and the approver allowed it + | 'approval-denied'; // require-approval rule matched and approval was denied/timed-out /** * Structured per-request activity emitted by the proxy runtime. It carries diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts index 29e0e9d06..bb96681e2 100644 --- a/packages/varlock/src/proxy/policy.test.ts +++ b/packages/varlock/src/proxy/policy.test.ts @@ -49,6 +49,41 @@ describe('evaluateProxyPolicy', () => { ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/danger'), both).verdict).toBe('deny'); }); + + test('an approve rule yields require-approval', () => { + const approveRules = [rule({ domain: ['api.x.com'], path: '/v1/refunds/**', approve: true })]; + expect(evaluateProxyPolicy(facts('api.x.com', 'POST', '/v1/refunds/42'), approveRules).verdict).toBe('require-approval'); + // a non-matching path falls through to allow + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/customers'), approveRules).verdict).toBe('allow'); + }); + + test('block beats require-approval beats allow', () => { + const layered = [ + rule({ domain: ['api.x.com'] }), // broad allow + rule({ domain: ['api.x.com'], path: '/admin/**', approve: true }), + rule({ domain: ['api.x.com'], path: '/admin/destroy', block: true }), + ]; + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/admin/destroy'), layered).verdict).toBe('deny'); + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/admin/settings'), layered).verdict).toBe('require-approval'); + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/public'), layered).verdict).toBe('allow'); + }); + + test('a more-specific allow exempts a path from a broad approve', () => { + const broadApprove = [ + rule({ domain: ['api.x.com'], approve: true }), // approve everything by default + rule({ domain: ['api.x.com'], path: '/health' }), // ...except this safe path + ]; + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/anything'), broadApprove).verdict).toBe('require-approval'); + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/health'), broadApprove).verdict).toBe('allow'); + }); + + test('on a specificity tie, require-approval wins over allow (more restrictive)', () => { + const tied = [ + rule({ domain: ['api.x.com'], path: '/v1/*' }), + rule({ domain: ['api.x.com'], path: '/v1/*', approve: true }), + ]; + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/x'), tied).verdict).toBe('require-approval'); + }); }); describe('getRequestScopedManagedItems', () => { diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts index f6e837a7a..54e395c0c 100644 --- a/packages/varlock/src/proxy/policy.ts +++ b/packages/varlock/src/proxy/policy.ts @@ -75,17 +75,22 @@ export function describeRule(rule: ProxyRule): string { if (rule.method !== undefined) parts.push(rule.method.toUpperCase()); if (rule.path !== undefined) parts.push(rule.path); if (rule.block) parts.push('block'); + if (rule.approve) parts.push('approve'); return parts.join(' '); } /** - * Evaluate the policy for a request. Precedence: - * 1. any matching `block` rule wins → deny - * 2. otherwise allow (injection scoping is handled separately, so an + * Evaluate the policy for a request. Precedence (most restrictive wins): + * 1. any matching `block` rule → deny + * 2. else the most-specific tier of non-block rules decides: an `approve` rule + * in that tier → require-approval, otherwise allow + * 3. no matching rule → allow (injection scoping is handled separately, so an * allow verdict doesn't imply a secret is injected) * - * `require-approval` is part of the verdict space so the approval layer drops in - * here later without changing callers; no rule produces it yet. + * Tie-break is deliberately conservative: within the most-specific tier an + * `approve` rule beats a plain allow, so a broad allow can't silently downgrade a + * specific require-approval, and a specific allow can still exempt a safe path + * from a broad approve. */ export function evaluateProxyPolicy(facts: RequestFacts, rules: Array): PolicyDecision { const matching = rules.filter((rule) => ruleMatchesFacts(rule, facts)); @@ -97,10 +102,21 @@ export function evaluateProxyPolicy(facts: RequestFacts, rules: Array return { verdict: 'deny', matchedRule: blockRule, reason: 'blocked by @proxy(block=true) rule' }; } - const allowRule = matching - .filter((rule) => !rule.block) - .sort((a, b) => ruleSpecificity(b) - ruleSpecificity(a))[0]; - return { verdict: 'allow', matchedRule: allowRule }; + const nonBlock = matching.filter((rule) => !rule.block); + if (nonBlock.length === 0) { + return { verdict: 'allow' }; + } + const maxSpecificity = Math.max(...nonBlock.map(ruleSpecificity)); + const topTier = nonBlock.filter((rule) => ruleSpecificity(rule) === maxSpecificity); + const approveRule = topTier.find((rule) => rule.approve); + if (approveRule) { + return { + verdict: 'require-approval', + matchedRule: approveRule, + reason: 'requires approval per @proxy(approve=true) rule', + }; + } + return { verdict: 'allow', matchedRule: topTier[0] }; } /** diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 79813e43b..ce02f93bb 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -266,6 +266,90 @@ describe('startLocalProxyRuntime', () => { }); }); + test('require-approval: a denied request never reaches upstream (fail closed)', async () => { + let upstreamHit = false; + const upstream = http.createServer((_req, res) => { + upstreamHit = true; + res.end('ok'); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [ + { + source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approve: true, + }, + ], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + approvalProvider: { async requestApproval(r) { return { approved: false, nonce: r.nonce }; } }, + }); + + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/v1/refunds`); + expect(response.statusCode).toBe(403); + expect(upstreamHit).toBe(false); + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ decision: 'approval-denied', blocked: true, path: '/v1/refunds' }); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + + test('require-approval: an approved request is forwarded and audited as approval-granted', async () => { + let upstreamHit = false; + const upstream = http.createServer((_req, res) => { + upstreamHit = true; + res.statusCode = 200; + res.end('ok'); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test upstream'); + + const seen: Array<{ method: string; path: string; bodyHash: string }> = []; + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [ + { + source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approve: true, + }, + ], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + approvalProvider: { + async requestApproval(r) { + // the provider is handed the request-bound details (Invariant #8) + seen.push({ method: r.method, path: r.path, bodyHash: r.bodyHash }); + return { approved: true, nonce: r.nonce }; + }, + }, + }); + + const response = await requestViaProxy(runtime.env.HTTP_PROXY!, `http://127.0.0.1:${addr.port}/v1/refunds`); + expect(response.statusCode).toBe(200); + expect(upstreamHit).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ method: 'GET', path: '/v1/refunds' }); + expect(activities).toHaveLength(1); + expect(activities[0]!.decision).toBe('approval-granted'); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + test('streams text/event-stream responses through incrementally (no buffering)', async () => { const INTER_CHUNK_DELAY = 200; const upstream = http.createServer((_req, res) => { diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 017eb1c71..ea9f55f8f 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -11,6 +11,9 @@ import { StringDecoder } from 'node:string_decoder'; import tls from 'node:tls'; import { URL } from 'node:url'; +import { + createApprovalRequest, isApprovalValid, type ApprovalProvider, +} from './approval'; import type { ProxyActivity } from './audit'; import { createEphemeralCa, createHostCert } from './cert-authority'; import { @@ -30,6 +33,11 @@ export type StartLocalProxyRuntimeInput = { rules: Array; egressMode: ProxyEgressMode; onActivity?: (activity: ProxyActivity) => void; + /** + * Called when a request matches a `require-approval` rule. Must fail closed + * (deny on timeout/error). Absent ⇒ require-approval requests are denied. + */ + approvalProvider?: ApprovalProvider; }; type HostInfo = { host: string, port: number }; @@ -85,6 +93,38 @@ function buildVerifiedUpstreamOptions(): { }; } +/** + * Run the request-bound approval gate (Invariant #8). Builds an ApprovalRequest + * committed to this exact request, asks the provider, and returns whether the + * decision actually authorizes it. Fails closed: no provider, a throwing + * provider, a nonce mismatch, or an expired/denied decision all return false. + */ +async function runApprovalGate(input: { + approvalProvider: ApprovalProvider | undefined; + method: string; + host: string; + path: string; + body: Buffer; + ruleId?: string; + injectedKeys: Array; +}): Promise { + if (!input.approvalProvider) return false; + const request = createApprovalRequest({ + method: input.method, + host: input.host, + path: input.path, + body: input.body, + ruleId: input.ruleId, + injectedKeys: input.injectedKeys, + }); + try { + const decision = await input.approvalProvider.requestApproval(request); + return isApprovalValid(request, decision); + } catch { + return false; + } +} + function replacePlaceholdersWithReal(value: string, managedItems: Array): string { let next = value; for (const item of managedItems) { @@ -343,6 +383,7 @@ export async function startLocalProxyRuntime({ rules, egressMode, onActivity, + approvalProvider, }: StartLocalProxyRuntimeInput): Promise { // Only the public CA cert is written to disk (for child trust). Private keys // — the CA's and every per-host leaf's — stay in memory; see cert-authority.ts. @@ -383,7 +424,8 @@ export async function startLocalProxyRuntime({ // + path against the rules; a matching `block` rule denies the request. const facts: RequestFacts = { host: hostInfo.host, method, path: pathOnly }; const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; - const ruleId = policyDecision?.matchedRule ? { ruleId: describeRule(policyDecision.matchedRule) } : {}; + const ruleIdStr = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; + const ruleId = ruleIdStr ? { ruleId: ruleIdStr } : {}; if (policyDecision?.verdict === 'deny') { onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', @@ -403,12 +445,32 @@ export async function startLocalProxyRuntime({ const injectedKeys = shouldRewrite ? detectInjectedKeys([rawUrl, JSON.stringify(req.headers), body.toString('utf8')], hostItems) : []; + + // Invariant #8: a require-approval rule holds the request for an out-of-band, + // request-bound decision. Fail closed (deny) unless explicitly approved. + if (policyDecision?.verdict === 'require-approval') { + const approved = await runApprovalGate({ + approvalProvider, method, host: hostInfo.host, path: pathOnly, body, ruleId: ruleIdStr, injectedKeys, + }); + if (!approved) { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', + }); + try { + res.writeHead(403, { 'content-type': 'text/plain', connection: 'close' }); + res.end('Request denied: approval not granted'); + } catch { /* response may already be gone */ } + res.socket?.destroy(); + return; + } + } + onActivity?.({ ...baseActivity, ...ruleId, matched: shouldRewrite, blocked: false, - decision: 'allow', + decision: policyDecision?.verdict === 'require-approval' ? 'approval-granted' : 'allow', ...(injectedKeys.length ? { injectedKeys } : {}), }); @@ -537,7 +599,8 @@ export async function startLocalProxyRuntime({ } const facts: RequestFacts = { host: destination.hostname, method, path: pathOnly }; const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; - const ruleId = policyDecision?.matchedRule ? { ruleId: describeRule(policyDecision.matchedRule) } : {}; + const ruleIdStr = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; + const ruleId = ruleIdStr ? { ruleId: ruleIdStr } : {}; if (policyDecision?.verdict === 'deny') { onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', @@ -565,12 +628,29 @@ export async function startLocalProxyRuntime({ const injectedKeys = shouldRewrite ? detectInjectedKeys([url, JSON.stringify(clientReq.headers), body.toString('utf8')], hostItems) : []; + + // Invariant #8: require-approval holds the request for an out-of-band, + // request-bound decision. Fail closed (deny) unless explicitly approved. + if (policyDecision?.verdict === 'require-approval') { + const approved = await runApprovalGate({ + approvalProvider, method, host: destination.hostname, path: pathOnly, body, ruleId: ruleIdStr, injectedKeys, + }); + if (!approved) { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', + }); + clientRes.statusCode = 403; + clientRes.end('Request denied: approval not granted'); + return; + } + } + onActivity?.({ ...baseActivity, ...ruleId, matched: shouldRewrite, blocked: false, - decision: 'allow', + decision: policyDecision?.verdict === 'require-approval' ? 'approval-granted' : 'allow', ...(injectedKeys.length ? { injectedKeys } : {}), }); diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index 00b1eb7c1..e17c143d5 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -9,6 +9,8 @@ export type ProxyRule = { path?: string; method?: string; block?: boolean; + /** Require out-of-band approval before this request is forwarded (Invariant #8). */ + approve?: boolean; }; export type ProxyManagedItem = { From 153d01dc7c2b4cf3354b67f5e602c011cb7c1c5c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 23:05:25 -0700 Subject: [PATCH 10/64] fix(varlock): allow @proxy in the schema header (detached rules) @proxy is registered as both item and root decorator, but the header placement validator rejected any item-registered name, making detached rules (and header block/approve rules) unauthorable. Accept a name that is also a root decorator. Also fixes attached rules being dropped when a header @proxy was present. --- .bumpy/proxy-root-decorator-fix.md | 7 +++++ .../varlock/src/env-graph/lib/data-source.ts | 5 +++- .../src/env-graph/test/proxy-mode.test.ts | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .bumpy/proxy-root-decorator-fix.md diff --git a/.bumpy/proxy-root-decorator-fix.md b/.bumpy/proxy-root-decorator-fix.md new file mode 100644 index 000000000..3b5c431e9 --- /dev/null +++ b/.bumpy/proxy-root-decorator-fix.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Fix: allow `@proxy(...)` in the `.env.schema` header for "detached" proxy rules. + +`@proxy` is both an item decorator (attached rules) and a root decorator (detached rules), but the header placement check rejected it as a misplaced item decorator, so detached rules — including header-level `block`/`approve` rules — couldn't be authored. A decorator registered as both is now accepted in the header. diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 87a1fc256..769a5b398 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -831,7 +831,10 @@ export class DotEnvFileDataSource extends FileBasedDataSource { // check for item decorators in the header, and duplicate non-fn root decorators const seenRootDecs = new Set(); for (const dec of parsedFile.decoratorsArray) { - if (dec.name in this.graph!.itemDecoratorsRegistry) { + // A name registered as BOTH an item and a root decorator (e.g. @proxy: + // item-level "attached" rules + header-level "detached" rules) is valid in + // the header — only reject names that are item-decorators and nothing else. + if (dec.name in this.graph!.itemDecoratorsRegistry && !(dec.name in this.graph!.rootDecoratorsRegistry)) { this._errors.push(new SchemaError( `Item decorator @${dec.name} cannot be used in the file header - it must be attached to a config item`, { location: this._locationFromParsed(dec) }, diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 92b728945..f5824f08b 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -46,6 +46,35 @@ describe('proxy decorators', () => { domain: ['api.example.com'], itemKeys: [], }, + { + source: 'attached', + domain: ['api.stripe.com'], + itemKeys: ['STRIPE_KEY'], + }, + ]); + }); + + test('a header-level (detached) @proxy is not rejected as a misplaced item decorator', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # @proxy(domain="api.a.com") + # @proxy(domain="api.b.com", path="/admin/**", approve=true) + # --- + BASELINE=1 + `); + + // @proxy is registered as both a root and item decorator; using it in the + // header must NOT raise "Item decorator @proxy cannot be used in the file header". + const errors = graph.sortedDataSources.flatMap((s) => s.errors).filter((e) => !e.isWarning); + expect(errors).toEqual([]); + + // ...and both detached rules are collected, including the approve rule. + const rules = await graph.getProxyRules(); + expect(rules).toMatchObject([ + { source: 'detached', domain: ['api.a.com'] }, + { + source: 'detached', domain: ['api.b.com'], path: '/admin/**', approve: true, + }, ]); }); From e6d97534819cab3637479d0617c488f24ae95d97 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 23:30:08 -0700 Subject: [PATCH 11/64] feat(varlock): proxy omits unhandled sensitive items by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fail-closed block (session refused when a sensitive item wasn't @proxy-managed or @proxyPassthrough) with default-omit: such items are withheld from the child (dropped from vars + the __VARLOCK_ENV blob) with a notice. Least privilege by default; no need to annotate every secret to start a session. Rename getBlockedSensitiveKeys → getOmittedSensitiveKeys. --- .bumpy/proxy-default-omit-sensitive.md | 7 ++++ .../varlock/src/cli/commands/proxy.command.ts | 39 ++++++++++++------- .../cli/commands/test/proxy.command.test.ts | 28 ++++++++++--- 3 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 .bumpy/proxy-default-omit-sensitive.md diff --git a/.bumpy/proxy-default-omit-sensitive.md b/.bumpy/proxy-default-omit-sensitive.md new file mode 100644 index 000000000..8681c62c2 --- /dev/null +++ b/.bumpy/proxy-default-omit-sensitive.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy: omit unhandled sensitive items by default instead of blocking the session. + +Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a notice listing them. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 1653a3b11..98c81584a 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -169,21 +169,27 @@ function createSessionStatsWriter(sessionUuid: string, initial?: ProxySessionSta }; } -export function getBlockedSensitiveKeys( +/** + * Sensitive items that are withheld (omitted) from the proxied child by default: + * those with a resolved value that are neither `@proxy`-managed (placeholder + * injected) nor `@proxyPassthrough` (real value injected). Least-privilege: the + * agent never sees a secret you didn't explicitly route or pass through. + */ +export function getOmittedSensitiveKeys( envGraph: Awaited>, proxyManagedItems: Array, ): Array { const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); - const blockedKeys: Array = []; + const omittedKeys: Array = []; for (const key of envGraph.sortedConfigKeys) { const item = envGraph.configSchema[key]; if (!item?.isSensitive) continue; if (item.resolvedValue === undefined) continue; if (managedKeys.has(key)) continue; if (item.getDec('proxyPassthrough')) continue; - blockedKeys.push(key); + omittedKeys.push(key); } - return blockedKeys; + return omittedKeys; } function quoteForShell(value: string): string { @@ -259,16 +265,21 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise { - test('blocks unmanaged sensitive keys by default', async () => { +describe('getOmittedSensitiveKeys', () => { + test('omits unmanaged sensitive keys by default', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -27,10 +27,11 @@ describe('getBlockedSensitiveKeys', () => { `); const managedItems = await graph.getProxyManagedItems(); - expect(getBlockedSensitiveKeys(graph, managedItems)).toEqual(['UNMANAGED_SECRET']); + // unmanaged + sensitive + no passthrough → omitted from the child + expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual(['UNMANAGED_SECRET']); }); - test('allows unmanaged sensitive key when @proxyPassthrough is present', async () => { + test('does not omit an unmanaged sensitive key marked @proxyPassthrough', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -45,6 +46,21 @@ describe('getBlockedSensitiveKeys', () => { `); const managedItems = await graph.getProxyManagedItems(); - expect(getBlockedSensitiveKeys(graph, managedItems)).toEqual([]); + expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual([]); + }); + + test('does not omit non-sensitive keys', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + PLAIN=just-a-value + + # @sensitive + # @proxy(domain="api.example.com") + PROXIED_SECRET=secret-proxied + `); + + const managedItems = await graph.getProxyManagedItems(); + expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual([]); }); }); From bc24b0afedfbe15c04ed1a6edbd517606f995c9f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 23:32:24 -0700 Subject: [PATCH 12/64] feat(varlock): warn (not just notice) when proxy omits unhandled sensitive vars Reword the default-omit message as a startup warning explaining the vars were omitted because no proxy policy is set for them. --- .bumpy/proxy-default-omit-sensitive.md | 2 +- packages/varlock/src/cli/commands/proxy.command.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpy/proxy-default-omit-sensitive.md b/.bumpy/proxy-default-omit-sensitive.md index 8681c62c2..a9d0976a2 100644 --- a/.bumpy/proxy-default-omit-sensitive.md +++ b/.bumpy/proxy-default-omit-sensitive.md @@ -4,4 +4,4 @@ varlock: patch Proxy: omit unhandled sensitive items by default instead of blocking the session. -Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a notice listing them. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. +Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a warning at startup listing the omitted vars. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 98c81584a..c210aa776 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -275,7 +275,7 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise Date: Fri, 12 Jun 2026 23:40:39 -0700 Subject: [PATCH 13/64] feat(varlock): exclude _VARLOCK_* reserved keys from proxy omit policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserved _VARLOCK_* keys are varlock's own internal plumbing, not user secrets, so getOmittedSensitiveKeys skips them — they're never flagged as omitted/needing a rule and pass through as infrastructure (consistent with normal varlock run). --- .bumpy/proxy-default-omit-sensitive.md | 2 +- .../varlock/src/cli/commands/proxy.command.ts | 5 +++++ .../src/cli/commands/test/proxy.command.test.ts | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.bumpy/proxy-default-omit-sensitive.md b/.bumpy/proxy-default-omit-sensitive.md index a9d0976a2..133b5736f 100644 --- a/.bumpy/proxy-default-omit-sensitive.md +++ b/.bumpy/proxy-default-omit-sensitive.md @@ -4,4 +4,4 @@ varlock: patch Proxy: omit unhandled sensitive items by default instead of blocking the session. -Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a warning at startup listing the omitted vars. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. +Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a warning at startup listing the omitted vars. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. Varlock's own `_VARLOCK_*` reserved keys are internal infrastructure and are excluded from this policy entirely (not omitted, not warned). diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index c210aa776..77c07a6b0 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -31,6 +31,7 @@ import { PROXY_PARENT_PID_ENV_VAR, } from '../../proxy/env-vars'; import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; +import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; import { resetRedactionMap, redactSensitiveConfig } from '../../runtime/env'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; @@ -182,6 +183,10 @@ export function getOmittedSensitiveKeys( const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); const omittedKeys: Array = []; for (const key of envGraph.sortedConfigKeys) { + // `_VARLOCK_*` keys are varlock's own internal plumbing (e.g. the env key), + // not user secrets — they're outside the proxy policy model entirely, so + // never treat them as omitted/needing a rule. + if (isVarlockReservedKey(key)) continue; const item = envGraph.configSchema[key]; if (!item?.isSensitive) continue; if (item.resolvedValue === undefined) continue; diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index e95dbe2b1..dac2c0cd8 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -49,6 +49,22 @@ describe('getOmittedSensitiveKeys', () => { expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual([]); }); + test('does not omit varlock-reserved (_VARLOCK_*) keys — they are internal infra', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @sensitive + _VARLOCK_ENV_KEY=deadbeef + + # @sensitive + USER_SECRET=some-secret + `); + + const managedItems = await graph.getProxyManagedItems(); + // _VARLOCK_ENV_KEY is internal plumbing, not a user secret needing a policy. + expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual(['USER_SECRET']); + }); + test('does not omit non-sensitive keys', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false From f1572a69e029caa29d65d70a45caf9357bb94f07 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 12 Jun 2026 23:54:15 -0700 Subject: [PATCH 14/64] feat(varlock): dual-form @proxy decorator (function or value), replacing @proxyPassthrough Adds isFunctionOrValue decorator capability: @proxy can be a function (@proxy(domain=...) to route) or a value (@proxy=passthrough to inject the real value, @proxy=omit to withhold explicitly). The two forms are mutually exclusive per item. Removes @proxyPassthrough. @proxy=omit suppresses the no-policy omit warning. --- .bumpy/proxy-dual-form-decorator.md | 7 ++ .../varlock/src/cli/commands/proxy.command.ts | 73 +++++++++++------- .../cli/commands/test/proxy.command.test.ts | 52 ++++++++----- .../varlock/src/env-graph/lib/config-item.ts | 10 +++ .../varlock/src/env-graph/lib/decorators.ts | 74 ++++++++++++------- .../src/env-graph/test/proxy-mode.test.ts | 45 +++++++++++ 6 files changed, 188 insertions(+), 73 deletions(-) create mode 100644 .bumpy/proxy-dual-form-decorator.md diff --git a/.bumpy/proxy-dual-form-decorator.md b/.bumpy/proxy-dual-form-decorator.md new file mode 100644 index 000000000..664179988 --- /dev/null +++ b/.bumpy/proxy-dual-form-decorator.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy: unify `@proxyPassthrough` into a dual-form `@proxy` decorator. + +`@proxy` can now be used as a function — `@proxy(domain=...)` to route a value through the proxy (the agent sees a placeholder) — or as a value: `@proxy=passthrough` injects the real value into the proxied child, and `@proxy=omit` explicitly withholds it (no "no policy set" warning). The two forms are mutually exclusive on a single item. This replaces the separate `@proxyPassthrough` decorator. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 77c07a6b0..764331f93 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -170,31 +170,43 @@ function createSessionStatsWriter(sessionUuid: string, initial?: ProxySessionSta }; } +/** The value-form `@proxy` mode for an item: `@proxy=passthrough` / `@proxy=omit` (or undefined). */ +function getProxyValueMode( + item: { getDec: (name: string) => { resolvedValue?: any } | undefined }, +): 'passthrough' | 'omit' | undefined { + const mode = item.getDec('proxy')?.resolvedValue; + return mode === 'passthrough' || mode === 'omit' ? mode : undefined; +} + /** - * Sensitive items that are withheld (omitted) from the proxied child by default: - * those with a resolved value that are neither `@proxy`-managed (placeholder - * injected) nor `@proxyPassthrough` (real value injected). Least-privilege: the - * agent never sees a secret you didn't explicitly route or pass through. + * Keys withheld (omitted) from the proxied child. An item is omitted when it's + * not `@proxy`-managed (placeholder) and not `@proxy=passthrough` (real value), + * and is either sensitive (omitted by default — least privilege) or explicitly + * marked `@proxy=omit`. `_VARLOCK_*` reserved keys are internal infra, never + * omitted. Returns `{ explicit }` so the caller can warn only about implicit + * omits ("no proxy policy set"). */ -export function getOmittedSensitiveKeys( +export function getProxyOmittedKeys( envGraph: Awaited>, proxyManagedItems: Array, -): Array { +): Array<{ key: string; explicit: boolean }> { const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); - const omittedKeys: Array = []; + const omitted: Array<{ key: string; explicit: boolean }> = []; for (const key of envGraph.sortedConfigKeys) { - // `_VARLOCK_*` keys are varlock's own internal plumbing (e.g. the env key), - // not user secrets — they're outside the proxy policy model entirely, so - // never treat them as omitted/needing a rule. if (isVarlockReservedKey(key)) continue; const item = envGraph.configSchema[key]; - if (!item?.isSensitive) continue; - if (item.resolvedValue === undefined) continue; + if (!item || item.resolvedValue === undefined) continue; if (managedKeys.has(key)) continue; - if (item.getDec('proxyPassthrough')) continue; - omittedKeys.push(key); + const mode = getProxyValueMode(item); + if (mode === 'passthrough') continue; // inject the real value + if (mode === 'omit') { + omitted.push({ key, explicit: true }); + continue; + } + if (!item.isSensitive) continue; // non-sensitive with no policy → injected normally + omitted.push({ key, explicit: false }); // sensitive default → omit (and warn) } - return omittedKeys; + return omitted; } function quoteForShell(value: string): string { @@ -270,22 +282,27 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise !o.explicit).map((o) => o.key); + if (warnKeys.length) { + console.error( + `⚠️ The following sensitive var(s) were omitted from the proxied child because no proxy policy is set for them: ${warnKeys.join(', ')}`, + ); + console.error( + ' Add @proxy(...) to route a value through the proxy (agent sees a placeholder), ' + + '@proxy=passthrough to inject the real value, or @proxy=omit to omit it explicitly.', + ); + } } for (const managedItem of proxyManagedItems) { diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index dac2c0cd8..8d551a476 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; -import { getOmittedSensitiveKeys } from '../proxy.command.js'; +import { getProxyOmittedKeys } from '../proxy.command.js'; async function loadGraph(envFile: string) { const graph = new EnvGraph(); @@ -12,8 +12,13 @@ async function loadGraph(envFile: string) { return graph; } -describe('getOmittedSensitiveKeys', () => { - test('omits unmanaged sensitive keys by default', async () => { +async function omittedKeys(graph: EnvGraph) { + const managedItems = await graph.getProxyManagedItems(); + return getProxyOmittedKeys(graph, managedItems); +} + +describe('getProxyOmittedKeys', () => { + test('omits an unmanaged sensitive key by default (implicit — warned)', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -26,27 +31,37 @@ describe('getOmittedSensitiveKeys', () => { UNMANAGED_SECRET=secret-unmanaged `); - const managedItems = await graph.getProxyManagedItems(); - // unmanaged + sensitive + no passthrough → omitted from the child - expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual(['UNMANAGED_SECRET']); + expect(await omittedKeys(graph)).toEqual([{ key: 'UNMANAGED_SECRET', explicit: false }]); }); - test('does not omit an unmanaged sensitive key marked @proxyPassthrough', async () => { + test('does not omit a sensitive key marked @proxy=passthrough', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- - BASELINE=1 + # @sensitive + # @proxy=passthrough + PASS_SECRET=secret-allowed + `); - # @proxy(domain="api.example.com") - PROXIED_SECRET=secret-proxied + expect(await omittedKeys(graph)).toEqual([]); + }); + + test('omits a key marked @proxy=omit explicitly (regardless of sensitivity)', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy=omit + PLAIN_BUT_OMITTED=whatever # @sensitive - # @proxyPassthrough - UNMANAGED_ALLOWED=secret-allowed + # @proxy=omit + SECRET_OMITTED=secret `); - const managedItems = await graph.getProxyManagedItems(); - expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual([]); + expect(await omittedKeys(graph)).toEqual([ + { key: 'PLAIN_BUT_OMITTED', explicit: true }, + { key: 'SECRET_OMITTED', explicit: true }, + ]); }); test('does not omit varlock-reserved (_VARLOCK_*) keys — they are internal infra', async () => { @@ -60,12 +75,10 @@ describe('getOmittedSensitiveKeys', () => { USER_SECRET=some-secret `); - const managedItems = await graph.getProxyManagedItems(); - // _VARLOCK_ENV_KEY is internal plumbing, not a user secret needing a policy. - expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual(['USER_SECRET']); + expect(await omittedKeys(graph)).toEqual([{ key: 'USER_SECRET', explicit: false }]); }); - test('does not omit non-sensitive keys', async () => { + test('does not omit non-sensitive keys with no policy', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -76,7 +89,6 @@ describe('getOmittedSensitiveKeys', () => { PROXIED_SECRET=secret-proxied `); - const managedItems = await graph.getProxyManagedItems(); - expect(getOmittedSensitiveKeys(graph, managedItems)).toEqual([]); + expect(await omittedKeys(graph)).toEqual([]); }); }); diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 6a53be25f..4f021e4da 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -320,6 +320,16 @@ export class ConfigItem { } } + // A dual-form decorator (function OR value, e.g. @proxy) is mutually exclusive + // per item: you can't both route with @proxy(...) and set @proxy=passthrough. + for (const [name, valueDec] of Object.entries(this.effectiveDecorators)) { + if (valueDec.isFunctionOrValue && this.effectiveDecoratorFns[name]?.length) { + valueDec._errors.push(new SchemaError( + `@${name} cannot be used as both a value (@${name}=...) and a function (@${name}(...)) on the same item`, + )); + } + } + const typeDec = this.getDec('type'); let dataTypeName: string | undefined; let dataTypeArgs: any; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 3b2e00d53..0d6b6436c 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -54,6 +54,9 @@ export abstract class DecoratorInstance { get incompatibleWith() { return this.decoratorDef?.incompatibleWith; } + get isFunctionOrValue() { + return !!(this.decoratorDef as ItemDecoratorDef | undefined)?.isFunctionOrValue; + } private processed = false; private processedData: any; @@ -106,27 +109,31 @@ export abstract class DecoratorInstance { )); } - // validate function-call vs value syntax - if (this.decoratorDef.isFunction && !this.isFunctionCall) { - throw new SchemaError( - `@${this.name} must be used as a function call - use @${this.name}(...) instead of @${this.name}=value`, - ); - } - if (!this.decoratorDef.isFunction && this.isFunctionCall) { - // bare fn-call syntax `@name(...)` is reserved for repeatable decorators (e.g. @docs()). - // @sensitive is single-use but accepts an options-object value — guide users who tried - // to pass options as a bare call toward the object form `@sensitive={...}`. - if (this.name === 'sensitive') { - const optsStr = this.parsedDecorator.bareFnArgs - ? `{${this.parsedDecorator.bareFnArgs.values.map((v) => v.toString()).join(', ')}}` - : '{preventLeaks=false}'; + // validate function-call vs value syntax. A decorator marked + // `isFunctionOrValue` accepts either form (e.g. @proxy(...) or @proxy=x); + // the two are kept mutually exclusive per item in ConfigItem.process. + if (!(this.decoratorDef as ItemDecoratorDef).isFunctionOrValue) { + if (this.decoratorDef.isFunction && !this.isFunctionCall) { throw new SchemaError( - `@sensitive is single-use and cannot be called like @sensitive(...). To pass options, use an object value: @sensitive=${optsStr}`, + `@${this.name} must be used as a function call - use @${this.name}(...) instead of @${this.name}=value`, + ); + } + if (!this.decoratorDef.isFunction && this.isFunctionCall) { + // bare fn-call syntax `@name(...)` is reserved for repeatable decorators (e.g. @docs()). + // @sensitive is single-use but accepts an options-object value — guide users who tried + // to pass options as a bare call toward the object form `@sensitive={...}`. + if (this.name === 'sensitive') { + const optsStr = this.parsedDecorator.bareFnArgs + ? `{${this.parsedDecorator.bareFnArgs.values.map((v) => v.toString()).join(', ')}}` + : '{preventLeaks=false}'; + throw new SchemaError( + `@sensitive is single-use and cannot be called like @sensitive(...). To pass options, use an object value: @sensitive=${optsStr}`, + ); + } + throw new SchemaError( + `@${this.name} cannot be used as a function call - use @${this.name}=value instead of @${this.name}(...)`, ); } - throw new SchemaError( - `@${this.name} cannot be used as a function call - use @${this.name}=value instead of @${this.name}(...)`, - ); } // this is so we can deal with @type, where each data type is not a real resolver @@ -536,6 +543,14 @@ export type ItemDecoratorDef = { name: string, incompatibleWith?: Array; isFunction?: boolean; + /** + * Allow BOTH the function form (`@name(...)`) and the value form (`@name=x`). + * The two forms are mutually exclusive on a single item (see ConfigItem.process). + * Used by `@proxy`: `@proxy(domain=...)` routes, `@proxy=passthrough|omit` are + * value-form modes. The `process` callback must handle both (discriminate on + * `decoratorValue.isStatic`). + */ + isFunctionOrValue?: boolean; deprecated?: boolean | string; process?: (decoratorValue: Resolver) => T | Promise; execute?: (executeInput: T) => void | Promise; @@ -595,23 +610,32 @@ export const builtInItemDecorators: Array> = [ { name: 'proxy', isFunction: true, + isFunctionOrValue: true, useFnArgsResolver: true, - process: (argsVal) => { - const domainResolver = argsVal.objArgs?.domain; + process: (decVal) => { + // Value form: @proxy=passthrough (inject the real value) or @proxy=omit + // (explicitly withhold from the proxied child). + if (decVal.isStatic) { + const mode = decVal.staticValue; + if (mode !== 'passthrough' && mode !== 'omit') { + throw new SchemaError( + '@proxy value must be "passthrough" or "omit" — or use @proxy(domain=...) to route a value through the proxy', + ); + } + return; + } + // Function form: @proxy(domain=..., [path], [method], [block], [approve], $REFS) + const domainResolver = decVal.objArgs?.domain; if (!domainResolver) { throw new SchemaError('@proxy: missing required "domain" option'); } - - for (const arg of argsVal.arrArgs || []) { + for (const arg of decVal.arrArgs || []) { if (arg.fnName !== 'ref') { throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); } } }, }, - { - name: 'proxyPassthrough', - }, // test-only decorators — dropped in release builds ...__VARLOCK_BUILD_TYPE__ === 'test' ? [ diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index f5824f08b..0d6e45f27 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -78,6 +78,51 @@ describe('proxy decorators', () => { ]); }); + test('@proxy=passthrough / =omit parse as value-form modes (no rule created)', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @sensitive + # @proxy=passthrough + PASS_KEY=real-value + + # @sensitive + # @proxy=omit + OMIT_KEY=real-value + `); + + expect(graph.configSchema.PASS_KEY.getDec('proxy')?.resolvedValue).toBe('passthrough'); + expect(graph.configSchema.OMIT_KEY.getDec('proxy')?.resolvedValue).toBe('omit'); + // value-form @proxy does not create a routing rule + expect(await graph.getProxyRules()).toEqual([]); + }); + + test('mixing @proxy=value and @proxy(...) on one item is an error', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @sensitive + # @proxy=passthrough + # @proxy(domain="api.x.com") + MIXED=secret + `); + + const errors = graph.configSchema.MIXED.decoratorSchemaErrors; + expect(errors.some((e) => /both a value .* and a function/.test(e.message))).toBe(true); + }); + + test('@proxy= is rejected', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy=nonsense + BAD=secret + `); + + const errors = graph.configSchema.BAD.decoratorSchemaErrors; + expect(errors.some((e) => /must be "passthrough" or "omit"/.test(e.message))).toBe(true); + }); + test('proxy managed items generate placeholders by priority', async () => { const graph = await loadGraph(outdent` # --- From ba72d242899f7a3a4081b75eb9145f9695f1c4e8 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 13 Jun 2026 00:21:57 -0700 Subject: [PATCH 15/64] feat(varlock): scoped proxy approvals (once/session/duration) with standing grants TTY prompt now offers [y]once [s]session [m]15min. Session/duration approvals persist as standing grants (per-session file, no secrets) so matching requests auto-approve without re-prompting. New approval-grants.ts decouples the grant store from the approver via createGrantingApprovalProvider, so the future phone/native-app approver reuses the same store. Enabled for proxy start; proxy run stays fail-closed. --- .bumpy/proxy-approval-scopes.md | 7 + .../varlock/src/cli/commands/proxy.command.ts | 26 ++- .../varlock/src/proxy/approval-grants.test.ts | 152 ++++++++++++++++++ packages/varlock/src/proxy/approval-grants.ts | 145 +++++++++++++++++ packages/varlock/src/proxy/approval.test.ts | 17 ++ packages/varlock/src/proxy/approval.ts | 49 ++++-- 6 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 .bumpy/proxy-approval-scopes.md create mode 100644 packages/varlock/src/proxy/approval-grants.test.ts create mode 100644 packages/varlock/src/proxy/approval-grants.ts diff --git a/.bumpy/proxy-approval-scopes.md b/.bumpy/proxy-approval-scopes.md new file mode 100644 index 000000000..d6e8cc2b8 --- /dev/null +++ b/.bumpy/proxy-approval-scopes.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy approvals: approve once, for the session, or for a time window. + +The `require-approval` terminal prompt (`varlock proxy start`) now offers scopes — `[y] once`, `[s] this session`, `[m] 15 min` — instead of a plain yes/no. A session- or duration-scoped approval is remembered as a standing grant (stored per session, no secret values) so later requests matching the same `@proxy(approve=true)` rule are auto-approved without re-prompting. Grants are decoupled from the approver behind a store, so the future phone / native-app approver reuses the same mechanism. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 764331f93..49964357c 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -15,6 +15,11 @@ import { createTtyApprovalProvider, type ApprovalProvider, } from '../../proxy/approval'; +import { + createApprovalGrantStore, + createGrantingApprovalProvider, + type ApprovalGrantStore, +} from '../../proxy/approval-grants'; import { cleanupStaleProxySessions, createProxySessionRecord, @@ -327,14 +332,24 @@ async function createRuntimeAndSession(opts: { entryPaths?: Array; command?: Array; approvalProvider: ApprovalProvider; + /** Persist session/duration approval scopes as standing grants (interactive sessions only). */ + enableApprovalGrants?: boolean; }): Promise<{ runtime: Awaited>; session: ProxySessionRecord; statsWriter: ReturnType; auditLog: ProxyAuditLog; + grantStore?: ApprovalGrantStore; }> { const identity = await reserveProxySessionIdentity(); const statsWriter = createSessionStatsWriter(identity.uuid, EMPTY_PROXY_SESSION_STATS); + // When grants are enabled, a session/duration approval is remembered so future + // matching requests auto-approve without re-prompting (the seam the phone + // approver reuses). Keyed to this session's uuid; torn down on stop. + const grantStore = opts.enableApprovalGrants ? createApprovalGrantStore(identity.uuid) : undefined; + const approvalProvider = grantStore + ? createGrantingApprovalProvider({ inner: opts.approvalProvider, store: grantStore }) + : opts.approvalProvider; const now = new Date().toISOString(); // Append-only audit log (Invariant #7): one entry per request decision, no // secret values. Persists after the session record is deleted on cleanup. @@ -350,7 +365,7 @@ async function createRuntimeAndSession(opts: { managedItems: opts.policy.proxyManagedItems, rules: opts.policy.proxyRules, egressMode: opts.policy.egressMode, - approvalProvider: opts.approvalProvider, + approvalProvider, onActivity: (activity) => { statsWriter.onActivity(activity); auditLog.record(activity); @@ -376,7 +391,7 @@ async function createRuntimeAndSession(opts: { }); return { - runtime, session, statsWriter, auditLog, + runtime, session, statsWriter, auditLog, grantStore, }; } @@ -579,13 +594,15 @@ async function runAction(ctx: any) { async function startAction(ctx: any) { const policy = await prepareProxyPolicy(ctx.values.path); const { - runtime, session, statsWriter, auditLog, + runtime, session, statsWriter, auditLog, grantStore, } = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, // The proxy owns this terminal (the agent runs elsewhere and routes through - // it), so require-approval requests can prompt here. + // it), so require-approval requests can prompt here — and session/duration + // approvals can be remembered as standing grants. approvalProvider: createTtyApprovalProvider(), + enableApprovalGrants: true, }); console.log(`Started proxy session ${session.id} (${session.uuid})`); @@ -600,6 +617,7 @@ async function startAction(ctx: any) { statsWriter.stop(); await runtime.stop().catch(() => undefined); await auditLog.flush().catch(() => undefined); + await grantStore?.destroy().catch(() => undefined); await deleteProxySessionRecord(session.uuid).catch(() => undefined); }; diff --git a/packages/varlock/src/proxy/approval-grants.test.ts b/packages/varlock/src/proxy/approval-grants.test.ts new file mode 100644 index 000000000..96e4e34cf --- /dev/null +++ b/packages/varlock/src/proxy/approval-grants.test.ts @@ -0,0 +1,152 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; + +import { + createApprovalGrantStore, + createGrantingApprovalProvider, + type ApprovalGrant, +} from './approval-grants'; +import { + createApprovalRequest, type ApprovalProvider, type ApprovalScope, +} from './approval'; + +// Redirect the grants dir into a throwaway XDG_CONFIG_HOME (grantsDir resolves lazily). +let tmpDir: string; +let prevXdg: string | undefined; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'varlock-grants-test-')); + prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmpDir; +}); + +afterEach(async () => { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + await rm(tmpDir, { recursive: true, force: true }); +}); + +const RULE = 'api.stripe.com POST /v1/refunds approve'; + +function grant(overrides: Partial = {}) { + return { + ruleId: RULE, host: 'api.stripe.com', scope: 'session' as const, grantedBy: 'tty', ...overrides, + }; +} + +describe('approval grant store', () => { + test('add + findMatch returns a session grant for the same rule', async () => { + const store = createApprovalGrantStore('sess-1'); + await store.add(grant()); + const match = await store.findMatch(RULE); + expect(match).toMatchObject({ ruleId: RULE, scope: 'session' }); + expect(match!.id).toMatch(/^[0-9a-f]+$/); + }); + + test('does not match a different rule', async () => { + const store = createApprovalGrantStore('sess-1'); + await store.add(grant()); + expect(await store.findMatch('some.other.rule')).toBeUndefined(); + }); + + test('a duration grant matches before expiry and not after', async () => { + const store = createApprovalGrantStore('sess-1'); + await store.add(grant({ scope: 'duration', expiresAt: Date.now() + 10_000 })); + expect(await store.findMatch(RULE)).toBeDefined(); + + const store2 = createApprovalGrantStore('sess-2'); + await store2.add(grant({ scope: 'duration', expiresAt: Date.now() - 1_000 })); + expect(await store2.findMatch(RULE)).toBeUndefined(); + }); + + test('grants are isolated per session (own file)', async () => { + const a = createApprovalGrantStore('sess-a'); + await a.add(grant()); + const b = createApprovalGrantStore('sess-b'); + expect(await b.findMatch(RULE)).toBeUndefined(); + }); + + test('destroy removes the grant file', async () => { + const store = createApprovalGrantStore('sess-x'); + await store.add(grant()); + expect(existsSync(store.filePath)).toBe(true); + await store.destroy(); + expect(existsSync(store.filePath)).toBe(false); + expect(await store.findMatch(RULE)).toBeUndefined(); + }); +}); + +function req(ruleId: string | undefined = RULE) { + return createApprovalRequest({ + method: 'POST', host: 'api.stripe.com', path: '/v1/refunds', body: 'x', ruleId, + }); +} + +describe('granting approval provider', () => { + test('persists a session-scope approval, then auto-approves without prompting again', async () => { + const store = createApprovalGrantStore('sess-1'); + let innerCalls = 0; + const inner: ApprovalProvider = { + async requestApproval(r) { + innerCalls += 1; + return { approved: true, nonce: r.nonce, scope: { kind: 'session' } as ApprovalScope }; + }, + }; + const provider = createGrantingApprovalProvider({ inner, store }); + + const first = await provider.requestApproval(req()); + expect(first.approved).toBe(true); + expect(innerCalls).toBe(1); + expect((await store.list())).toHaveLength(1); + + // second matching request → auto-approved, inner NOT called again + const second = await provider.requestApproval(req()); + expect(second.approved).toBe(true); + expect(second.reason).toMatch(/auto-approved/); + expect(innerCalls).toBe(1); + }); + + test('a once-scope approval is not persisted (re-prompts next time)', async () => { + const store = createApprovalGrantStore('sess-1'); + let innerCalls = 0; + const inner: ApprovalProvider = { + async requestApproval(r) { + innerCalls += 1; + return { approved: true, nonce: r.nonce, scope: { kind: 'once' } }; + }, + }; + const provider = createGrantingApprovalProvider({ inner, store }); + + await provider.requestApproval(req()); + await provider.requestApproval(req()); + expect(innerCalls).toBe(2); + expect(await store.list()).toHaveLength(0); + }); + + test('a denial is not persisted and passes through', async () => { + const store = createApprovalGrantStore('sess-1'); + const inner: ApprovalProvider = { + async requestApproval(r) { return { approved: false, nonce: r.nonce }; }, + }; + const provider = createGrantingApprovalProvider({ inner, store }); + expect((await provider.requestApproval(req())).approved).toBe(false); + expect(await store.list()).toHaveLength(0); + }); + + test('a duration approval persists with an expiry and records grantedBy', async () => { + const store = createApprovalGrantStore('sess-1'); + const inner: ApprovalProvider = { + async requestApproval(r) { return { approved: true, nonce: r.nonce, scope: { kind: 'duration', durationMs: 60_000 } }; }, + }; + const provider = createGrantingApprovalProvider({ inner, store, grantedBy: 'tty' }); + await provider.requestApproval(req()); + const [g] = await store.list(); + expect(g).toMatchObject({ scope: 'duration', grantedBy: 'tty' }); + expect(g!.expiresAt).toBeGreaterThan(Date.now()); + }); +}); diff --git a/packages/varlock/src/proxy/approval-grants.ts b/packages/varlock/src/proxy/approval-grants.ts new file mode 100644 index 000000000..0b33de6ff --- /dev/null +++ b/packages/varlock/src/proxy/approval-grants.ts @@ -0,0 +1,145 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { + appendFile, mkdir, readFile, rm, +} from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getUserVarlockDir } from '../lib/user-config-dir'; +import type { ApprovalProvider } from './approval'; + +/** + * A standing approval grant: the approver's choice to extend an interactive + * approval to *future* requests matching the same policy rule, for a scope. + * Holds no secret values. Stored per session so it can't outlive its session and + * cleans up trivially. The file is the source of truth (read on each + * require-approval), which is what lets an external approver (the future phone / + * native app) write a grant the proxy immediately honors. + */ +export type ApprovalGrant = { + id: string; + /** The approve-rule (describeRule) this grant covers — the match key. */ + ruleId: string; + /** For display/audit; the rule already constrains host/path/method. */ + host: string; + scope: 'session' | 'duration'; + /** Epoch ms; required for `duration` scope. */ + expiresAt?: number; + grantedAt: string; + /** Provenance — `tty` now, `phone`/`app` later. */ + grantedBy: string; +}; + +function grantsDir(): string { + return join(getUserVarlockDir(), 'proxy', 'grants'); +} + +export function getGrantFilePath(sessionUuid: string): string { + return join(grantsDir(), `${sessionUuid}.jsonl`); +} + +function isGrantValid(grant: ApprovalGrant, now: number): boolean { + if (grant.scope === 'session') return true; + if (grant.scope === 'duration') return typeof grant.expiresAt === 'number' && now < grant.expiresAt; + return false; +} + +/** File-backed, append-only grant store scoped to one proxy session. */ +export function createApprovalGrantStore(sessionUuid: string) { + const filePath = getGrantFilePath(sessionUuid); + + const list = async (): Promise> => { + if (!existsSync(filePath)) return []; + const raw = await readFile(filePath, 'utf8'); + const grants: Array = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + grants.push(JSON.parse(trimmed) as ApprovalGrant); + } catch { + // skip a torn line rather than fail the whole read + } + } + return grants; + }; + + return { + filePath, + list, + /** Append a grant. Best-effort: never throws (grant persistence must not break the proxy). */ + async add(grant: Omit & { id?: string; grantedAt?: string }): Promise { + const full: ApprovalGrant = { + id: grant.id ?? randomBytes(8).toString('hex'), + grantedAt: grant.grantedAt ?? new Date().toISOString(), + ruleId: grant.ruleId, + host: grant.host, + scope: grant.scope, + grantedBy: grant.grantedBy, + ...(grant.expiresAt !== undefined ? { expiresAt: grant.expiresAt } : {}), + }; + try { + await mkdir(grantsDir(), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(full)}\n`, { mode: 0o600 }); + } catch { + // best-effort + } + return full; + }, + /** The first still-valid grant covering `ruleId`, or undefined. */ + async findMatch(ruleId: string): Promise { + const now = Date.now(); + return (await list()).find((g) => g.ruleId === ruleId && isGrantValid(g, now)); + }, + /** Remove the session's grant file (called on session stop). */ + async destroy(): Promise { + await rm(filePath, { force: true }).catch(() => undefined); + }, + }; +} + +export type ApprovalGrantStore = ReturnType; + +/** + * Wrap a base approval provider (TTY now, phone later) with a standing-grant + * store: honor an existing grant for the request's rule without prompting, and + * persist a new grant when the approver chooses a `session`/`duration` scope. + * This is the seam where remembering decisions is decoupled from making them. + */ +export function createGrantingApprovalProvider(opts: { + inner: ApprovalProvider; + store: ApprovalGrantStore; + grantedBy?: string; +}): ApprovalProvider { + return { + async requestApproval(req) { + if (req.ruleId) { + const grant = await opts.store.findMatch(req.ruleId); + if (grant) { + return { + approved: true, + nonce: req.nonce, + scope: { kind: 'once' }, // already persisted; don't re-store + reason: `auto-approved by ${grant.scope} grant (${grant.grantedBy})`, + }; + } + } + + const decision = await opts.inner.requestApproval(req); + + if (decision.approved && req.ruleId && decision.scope && decision.scope.kind !== 'once') { + await opts.store.add({ + ruleId: req.ruleId, + host: req.host, + scope: decision.scope.kind === 'session' ? 'session' : 'duration', + grantedBy: opts.grantedBy ?? 'tty', + ...(decision.scope.kind === 'duration' + ? { expiresAt: Date.now() + decision.scope.durationMs } + : {}), + }); + } + + return decision; + }, + }; +} diff --git a/packages/varlock/src/proxy/approval.test.ts b/packages/varlock/src/proxy/approval.test.ts index 2b0ffa98e..e561e62fb 100644 --- a/packages/varlock/src/proxy/approval.test.ts +++ b/packages/varlock/src/proxy/approval.test.ts @@ -73,9 +73,26 @@ describe('createTtyApprovalProvider', () => { input.write('y\n'); const decision = await pending; expect(decision.approved).toBe(true); + expect(decision.scope).toEqual({ kind: 'once' }); expect(isApprovalValid(r, decision)).toBe(true); }); + test('"s" approves for the session, "m" for a duration window', async () => { + const sInput = ttyInput(); + const sProvider = createTtyApprovalProvider({ input: sInput, output: new PassThrough() }); + const sPending = sProvider.requestApproval(req()); + sInput.write('s\n'); + expect((await sPending).scope).toEqual({ kind: 'session' }); + + const mInput = ttyInput(); + const mProvider = createTtyApprovalProvider({ input: mInput, output: new PassThrough() }); + const mPending = mProvider.requestApproval(req()); + mInput.write('m\n'); + const mDecision = await mPending; + expect(mDecision.approved).toBe(true); + expect(mDecision.scope).toMatchObject({ kind: 'duration' }); + }); + test('denies on "n" (and anything that is not yes)', async () => { for (const answer of ['n\n', '\n', 'maybe\n']) { const input = ttyInput(); diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts index 1d7e9cb76..919b1cbb0 100644 --- a/packages/varlock/src/proxy/approval.ts +++ b/packages/varlock/src/proxy/approval.ts @@ -27,10 +27,21 @@ export type ApprovalRequest = { injectedKeys?: Array; }; +/** + * How long an approval lasts. `once` (default) authorizes only this request; the + * others authorize future requests matching the same rule (a standing grant — + * see approval-grants.ts) until the session ends or the window elapses. + */ +export type ApprovalScope = | { kind: 'once' } + | { kind: 'session' } + | { kind: 'duration'; durationMs: number }; + export type ApprovalDecision = { approved: boolean; /** Echoes the request nonce the approver acted on; must match to be honored. */ nonce: string; + /** How long this approval lasts. Absent ⇒ `once`. */ + scope?: ApprovalScope; reason?: string; }; @@ -87,11 +98,35 @@ export function createAutoDenyApprovalProvider(): ApprovalProvider { }; } +/** Default window for a "for a while" (`m`) terminal approval. */ +export const DEFAULT_GRANT_DURATION_MS = 15 * 60_000; + +/** Maps a single-key terminal answer to a decision + scope. Anything not an explicit yes denies. */ +function parseTtyAnswer(answer: string, nonce: string): ApprovalDecision { + const a = answer.trim().toLowerCase(); + if (a === 'y' || a === 'yes' || a === 'o') { + return { + approved: true, nonce, scope: { kind: 'once' }, reason: 'approved once at terminal', + }; + } + if (a === 's') { + return { + approved: true, nonce, scope: { kind: 'session' }, reason: 'approved for session at terminal', + }; + } + if (a === 'm') { + return { + approved: true, nonce, scope: { kind: 'duration', durationMs: DEFAULT_GRANT_DURATION_MS }, reason: 'approved for a window at terminal', + }; + } + return { approved: false, nonce, reason: 'denied at terminal' }; +} + /** * Prompts for approval on the proxy process's controlling terminal. Suited to * `varlock proxy start`, where the agent runs elsewhere and the proxy owns the - * TTY. Fails closed: denies on a non-TTY, timeout, EOF, or any answer other than - * an explicit yes. + * TTY. Offers scope choices (once / session / window) and fails closed: denies on + * a non-TTY, timeout, EOF, or any answer other than an explicit yes. */ export function createTtyApprovalProvider(opts?: { input?: Readable & { isTTY?: boolean }; @@ -101,6 +136,7 @@ export function createTtyApprovalProvider(opts?: { const input = opts?.input ?? process.stdin; const output = opts?.output ?? process.stderr; const timeoutMs = opts?.timeoutMs ?? DEFAULT_TTL_MS; + const minutes = Math.round(DEFAULT_GRANT_DURATION_MS / 60_000); return { async requestApproval(req) { @@ -112,7 +148,7 @@ export function createTtyApprovalProvider(opts?: { const prompt = '\n🔐 varlock proxy — approval required\n' + ` ${req.method} https://${req.host}${req.path}${inj}\n${ req.ruleId ? ` rule: ${req.ruleId}\n` : '' - } Approve this request? [y/N] `; + } Approve? [y] once [s] this session [m] ${minutes} min [n] no `; const rl = readline.createInterface({ input, output }); const answer = await new Promise((resolve) => { @@ -132,12 +168,7 @@ export function createTtyApprovalProvider(opts?: { }); rl.close(); - const approved = /^y(es)?$/i.test(answer.trim()); - return { - approved, - nonce: req.nonce, - reason: approved ? 'approved at terminal' : 'denied at terminal', - }; + return parseTtyAnswer(answer, req.nonce); }, }; } From 436871ac6e8cd2a30def24ca3bf573c8bd8da961 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 13 Jun 2026 09:48:59 -0700 Subject: [PATCH 16/64] feat(varlock): proxy approval granularity (approvalEach) + max-duration cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @proxy(approval=true) gains approvalEach (host|endpoint|request) and approvalMaxDuration (e.g. 15m, or 0=always-ask). Grants are keyed by rule + granularity so one rule yields fine-grained approvals; the lifetime is clamped to approvalMaxDuration proxy-side (schema is the ceiling). Renames approve→approval and runtime ApprovalScope→ApprovalLifetime. Interim flat props (object form when that branch lands). --- .bumpy/proxy-approval-granularity.md | 7 + .../varlock/src/env-graph/lib/decorators.ts | 20 ++- .../varlock/src/env-graph/lib/env-graph.ts | 26 +++- .../src/env-graph/test/proxy-mode.test.ts | 36 ++++- .../varlock/src/proxy/approval-grants.test.ts | 121 +++++++++------- packages/varlock/src/proxy/approval-grants.ts | 69 +++++---- packages/varlock/src/proxy/approval.test.ts | 39 +++++- packages/varlock/src/proxy/approval.ts | 132 ++++++++++++++---- packages/varlock/src/proxy/policy.test.ts | 8 +- packages/varlock/src/proxy/policy.ts | 16 +-- .../varlock/src/proxy/runtime-proxy.test.ts | 4 +- packages/varlock/src/proxy/runtime-proxy.ts | 28 +++- packages/varlock/src/proxy/types.ts | 18 ++- 13 files changed, 393 insertions(+), 131 deletions(-) create mode 100644 .bumpy/proxy-approval-granularity.md diff --git a/.bumpy/proxy-approval-granularity.md b/.bumpy/proxy-approval-granularity.md new file mode 100644 index 000000000..ab5de487e --- /dev/null +++ b/.bumpy/proxy-approval-granularity.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy approvals: per-rule granularity and a max-duration cap. + +`@proxy(approval=true)` rules now accept `approvalEach` — how finely to ask: `host`, `endpoint` (method+path), or `request` (method+path+body) — and `approvalMaxDuration`, the ceiling on how long a "yes" is remembered (e.g. `"15m"`, or `0` for always-ask). A standing grant is keyed by the matched rule plus its granularity, so one broad rule can yield per-endpoint or per-exact-request approvals without writing many rules. The cap is enforced proxy-side: the approver's chosen lifetime is clamped to `approvalMaxDuration`, so no approver can exceed what the schema allows (always-ask is provably one-tap-per-request). The `@proxy(approve=…)` option is renamed to `approval`. diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 0d6b6436c..c3bd44bbd 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -11,6 +11,8 @@ import { StaticValueResolver, type Resolver, convertParsedValueToResolvers } fro 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 } from '../../proxy/types'; export abstract class DecoratorInstance { @@ -624,7 +626,7 @@ export const builtInItemDecorators: Array> = [ } return; } - // Function form: @proxy(domain=..., [path], [method], [block], [approve], $REFS) + // Function form: @proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], $REFS) const domainResolver = decVal.objArgs?.domain; if (!domainResolver) { throw new SchemaError('@proxy: missing required "domain" option'); @@ -634,6 +636,22 @@ export const builtInItemDecorators: Array> = [ throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); } } + const eachResolver = decVal.objArgs?.approvalEach; + if (eachResolver?.isStatic) { + const eachVal = eachResolver.staticValue; + if (typeof eachVal !== 'string' || !PROXY_APPROVAL_EACH_VALUES.includes(eachVal as any)) { + throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); + } + } + const maxDurResolver = decVal.objArgs?.approvalMaxDuration; + if (maxDurResolver?.isStatic) { + const maxVal = maxDurResolver.staticValue; + try { + parseDuration(maxVal as string | number); + } catch { + throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); + } + } }, }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index fa37e7c9e..6e62a7f2c 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -28,7 +28,10 @@ import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; import { isVarlockReservedKey } from './reserved-vars'; import { buildOverrideProvenanceMetadata, type OverrideProvenanceMetadata } from '../../lib/injected-env-provenance'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; -import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from '../../proxy/types'; +import type { + ProxyApprovalEach, ProxyEgressMode, ProxyManagedItem, ProxyRule, +} from '../../proxy/types'; +import { parseDuration } from '../../lib/duration'; const processExists = !!globalThis.process; const originalProcessEnv = { ...processExists && process.env }; @@ -898,6 +901,23 @@ export class EnvGraph { return itemKeys; } + /** + * Approval fields for a rule, from a resolved `@proxy(...)` arg object. + * Approval is required if `approval=true` or any approval config prop is set. + */ + private static buildProxyApprovalFields(obj: any): Partial { + const approvalEach = _.isString(obj?.approvalEach) ? (obj.approvalEach as ProxyApprovalEach) : undefined; + const approvalMaxDurationMs = obj?.approvalMaxDuration !== undefined + ? parseDuration(obj.approvalMaxDuration) + : undefined; + const required = obj?.approval === true || approvalEach !== undefined || approvalMaxDurationMs !== undefined; + return { + ...(required ? { approval: true } : {}), + ...(approvalEach ? { approvalEach } : {}), + ...(approvalMaxDurationMs !== undefined ? { approvalMaxDurationMs } : {}), + }; + } + async getProxyRules(): Promise> { const rules: Array = []; @@ -914,7 +934,7 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...(_.isBoolean(resolved?.obj?.approve) ? { approve: resolved.obj.approve } : {}), + ...EnvGraph.buildProxyApprovalFields(resolved?.obj), }); } @@ -935,7 +955,7 @@ export class EnvGraph { ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...(_.isBoolean(resolved?.obj?.approve) ? { approve: resolved.obj.approve } : {}), + ...EnvGraph.buildProxyApprovalFields(resolved?.obj), }); } } diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 0d6e45f27..98509f87e 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -58,7 +58,7 @@ describe('proxy decorators', () => { const graph = await loadGraph(outdent` # @enableProxy(egress="strict") # @proxy(domain="api.a.com") - # @proxy(domain="api.b.com", path="/admin/**", approve=true) + # @proxy(domain="api.b.com", path="/admin/**", approval=true) # --- BASELINE=1 `); @@ -73,11 +73,43 @@ describe('proxy decorators', () => { expect(rules).toMatchObject([ { source: 'detached', domain: ['api.a.com'] }, { - source: 'detached', domain: ['api.b.com'], path: '/admin/**', approve: true, + source: 'detached', domain: ['api.b.com'], path: '/admin/**', approval: true, }, ]); }); + test('approval config: approvalEach + approvalMaxDuration parse onto the rule', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # @proxy(domain="api.a.com", approval=true) + # @proxy(domain="api.b.com", approvalEach="request", approvalMaxDuration="15m") + # @proxy(domain="api.c.com", approvalEach="host", approvalMaxDuration=0) + # --- + BASELINE=1 + `); + + expect(await graph.getProxyRules()).toMatchObject([ + { domain: ['api.a.com'], approval: true }, + { + domain: ['api.b.com'], approval: true, approvalEach: 'request', approvalMaxDurationMs: 900_000, + }, + { + domain: ['api.c.com'], approval: true, approvalEach: 'host', approvalMaxDurationMs: 0, + }, + ]); + }); + + test('approval config: a bad approvalEach is rejected', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", approvalEach="bogus") + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /approvalEach must be one of/.test(e.message))).toBe(true); + }); + test('@proxy=passthrough / =omit parse as value-form modes (no rule created)', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false diff --git a/packages/varlock/src/proxy/approval-grants.test.ts b/packages/varlock/src/proxy/approval-grants.test.ts index 96e4e34cf..e36be3f23 100644 --- a/packages/varlock/src/proxy/approval-grants.test.ts +++ b/packages/varlock/src/proxy/approval-grants.test.ts @@ -12,7 +12,7 @@ import { type ApprovalGrant, } from './approval-grants'; import { - createApprovalRequest, type ApprovalProvider, type ApprovalScope, + createApprovalRequest, type ApprovalLifetime, type ApprovalProvider, } from './approval'; // Redirect the grants dir into a throwaway XDG_CONFIG_HOME (grantsDir resolves lazily). @@ -31,44 +31,49 @@ afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); -const RULE = 'api.stripe.com POST /v1/refunds approve'; +const KEY = 'api.stripe.com approval POST api.stripe.com/v1/refunds'; function grant(overrides: Partial = {}) { return { - ruleId: RULE, host: 'api.stripe.com', scope: 'session' as const, grantedBy: 'tty', ...overrides, + grantKey: KEY, + ruleId: 'api.stripe.com approval', + host: 'api.stripe.com', + kind: 'session' as const, + grantedBy: 'tty', + ...overrides, }; } describe('approval grant store', () => { - test('add + findMatch returns a session grant for the same rule', async () => { + test('add + findMatch returns a session grant for the same key', async () => { const store = createApprovalGrantStore('sess-1'); await store.add(grant()); - const match = await store.findMatch(RULE); - expect(match).toMatchObject({ ruleId: RULE, scope: 'session' }); + const match = await store.findMatch(KEY); + expect(match).toMatchObject({ grantKey: KEY, kind: 'session' }); expect(match!.id).toMatch(/^[0-9a-f]+$/); }); - test('does not match a different rule', async () => { + test('does not match a different key', async () => { const store = createApprovalGrantStore('sess-1'); await store.add(grant()); - expect(await store.findMatch('some.other.rule')).toBeUndefined(); + expect(await store.findMatch('some other key')).toBeUndefined(); }); test('a duration grant matches before expiry and not after', async () => { const store = createApprovalGrantStore('sess-1'); - await store.add(grant({ scope: 'duration', expiresAt: Date.now() + 10_000 })); - expect(await store.findMatch(RULE)).toBeDefined(); + await store.add(grant({ kind: 'duration', expiresAt: Date.now() + 10_000 })); + expect(await store.findMatch(KEY)).toBeDefined(); const store2 = createApprovalGrantStore('sess-2'); - await store2.add(grant({ scope: 'duration', expiresAt: Date.now() - 1_000 })); - expect(await store2.findMatch(RULE)).toBeUndefined(); + await store2.add(grant({ kind: 'duration', expiresAt: Date.now() - 1_000 })); + expect(await store2.findMatch(KEY)).toBeUndefined(); }); test('grants are isolated per session (own file)', async () => { const a = createApprovalGrantStore('sess-a'); await a.add(grant()); const b = createApprovalGrantStore('sess-b'); - expect(await b.findMatch(RULE)).toBeUndefined(); + expect(await b.findMatch(KEY)).toBeUndefined(); }); test('destroy removes the grant file', async () => { @@ -77,76 +82,90 @@ describe('approval grant store', () => { expect(existsSync(store.filePath)).toBe(true); await store.destroy(); expect(existsSync(store.filePath)).toBe(false); - expect(await store.findMatch(RULE)).toBeUndefined(); + expect(await store.findMatch(KEY)).toBeUndefined(); }); }); -function req(ruleId: string | undefined = RULE) { +function req(opts: { maxDurationMs?: number; ruleId?: string } = {}) { return createApprovalRequest({ - method: 'POST', host: 'api.stripe.com', path: '/v1/refunds', body: 'x', ruleId, + method: 'POST', + host: 'api.stripe.com', + path: '/v1/refunds', + body: 'x', + ruleId: opts.ruleId ?? 'api.stripe.com approval', + ...(opts.maxDurationMs !== undefined ? { maxDurationMs: opts.maxDurationMs } : {}), }); } -describe('granting approval provider', () => { - test('persists a session-scope approval, then auto-approves without prompting again', async () => { - const store = createApprovalGrantStore('sess-1'); - let innerCalls = 0; - const inner: ApprovalProvider = { +function innerReturning(lifetime: ApprovalLifetime): { provider: ApprovalProvider; calls: () => number } { + let calls = 0; + return { + provider: { async requestApproval(r) { - innerCalls += 1; - return { approved: true, nonce: r.nonce, scope: { kind: 'session' } as ApprovalScope }; + calls += 1; + return { approved: true, nonce: r.nonce, lifetime }; }, - }; - const provider = createGrantingApprovalProvider({ inner, store }); + }, + calls: () => calls, + }; +} + +describe('granting approval provider', () => { + test('persists a session approval, then auto-approves without prompting again', async () => { + const store = createApprovalGrantStore('sess-1'); + const inner = innerReturning({ kind: 'session' }); + const provider = createGrantingApprovalProvider({ inner: inner.provider, store }); - const first = await provider.requestApproval(req()); - expect(first.approved).toBe(true); - expect(innerCalls).toBe(1); - expect((await store.list())).toHaveLength(1); + expect((await provider.requestApproval(req())).approved).toBe(true); + expect(inner.calls()).toBe(1); + expect(await store.list()).toHaveLength(1); - // second matching request → auto-approved, inner NOT called again const second = await provider.requestApproval(req()); expect(second.approved).toBe(true); expect(second.reason).toMatch(/auto-approved/); - expect(innerCalls).toBe(1); + expect(inner.calls()).toBe(1); // inner not consulted again }); - test('a once-scope approval is not persisted (re-prompts next time)', async () => { + test('a once approval is not persisted (re-prompts)', async () => { const store = createApprovalGrantStore('sess-1'); - let innerCalls = 0; - const inner: ApprovalProvider = { - async requestApproval(r) { - innerCalls += 1; - return { approved: true, nonce: r.nonce, scope: { kind: 'once' } }; - }, - }; - const provider = createGrantingApprovalProvider({ inner, store }); - + const inner = innerReturning({ kind: 'once' }); + const provider = createGrantingApprovalProvider({ inner: inner.provider, store }); await provider.requestApproval(req()); await provider.requestApproval(req()); - expect(innerCalls).toBe(2); + expect(inner.calls()).toBe(2); expect(await store.list()).toHaveLength(0); }); test('a denial is not persisted and passes through', async () => { const store = createApprovalGrantStore('sess-1'); const inner: ApprovalProvider = { - async requestApproval(r) { return { approved: false, nonce: r.nonce }; }, + async requestApproval(r) { + return { approved: false, nonce: r.nonce }; + }, }; const provider = createGrantingApprovalProvider({ inner, store }); expect((await provider.requestApproval(req())).approved).toBe(false); expect(await store.list()).toHaveLength(0); }); - test('a duration approval persists with an expiry and records grantedBy', async () => { + test('maxDuration clamps a session approval to a bounded duration grant', async () => { const store = createApprovalGrantStore('sess-1'); - const inner: ApprovalProvider = { - async requestApproval(r) { return { approved: true, nonce: r.nonce, scope: { kind: 'duration', durationMs: 60_000 } }; }, - }; - const provider = createGrantingApprovalProvider({ inner, store, grantedBy: 'tty' }); - await provider.requestApproval(req()); + const inner = innerReturning({ kind: 'session' }); + const provider = createGrantingApprovalProvider({ inner: inner.provider, store }); + await provider.requestApproval(req({ maxDurationMs: 60_000 })); const [g] = await store.list(); - expect(g).toMatchObject({ scope: 'duration', grantedBy: 'tty' }); - expect(g!.expiresAt).toBeGreaterThan(Date.now()); + expect(g.kind).toBe('duration'); + expect(g.expiresAt).toBeGreaterThan(Date.now()); + expect(g.expiresAt).toBeLessThanOrEqual(Date.now() + 60_000); + }); + + test('maxDuration=0 (always ask) never stores or honors a grant', async () => { + const store = createApprovalGrantStore('sess-1'); + const inner = innerReturning({ kind: 'session' }); + const provider = createGrantingApprovalProvider({ inner: inner.provider, store }); + await provider.requestApproval(req({ maxDurationMs: 0 })); + await provider.requestApproval(req({ maxDurationMs: 0 })); + expect(inner.calls()).toBe(2); // always re-asked + expect(await store.list()).toHaveLength(0); // never remembered }); }); diff --git a/packages/varlock/src/proxy/approval-grants.ts b/packages/varlock/src/proxy/approval-grants.ts index 0b33de6ff..70352a770 100644 --- a/packages/varlock/src/proxy/approval-grants.ts +++ b/packages/varlock/src/proxy/approval-grants.ts @@ -6,24 +6,26 @@ import { import { join } from 'node:path'; import { getUserVarlockDir } from '../lib/user-config-dir'; -import type { ApprovalProvider } from './approval'; +import { clampLifetime, type ApprovalProvider } from './approval'; /** * A standing approval grant: the approver's choice to extend an interactive - * approval to *future* requests matching the same policy rule, for a scope. - * Holds no secret values. Stored per session so it can't outlive its session and + * approval to *future* requests with the same grant key, for a lifetime. Holds + * no secret values. Stored per session so it can't outlive its session and * cleans up trivially. The file is the source of truth (read on each * require-approval), which is what lets an external approver (the future phone / * native app) write a grant the proxy immediately honors. */ export type ApprovalGrant = { id: string; - /** The approve-rule (describeRule) this grant covers — the match key. */ + /** Match key: rule + granularity (`each`). The actual lookup key. */ + grantKey: string; + /** The approve-rule (describeRule) — for display/audit. */ ruleId: string; /** For display/audit; the rule already constrains host/path/method. */ host: string; - scope: 'session' | 'duration'; - /** Epoch ms; required for `duration` scope. */ + kind: 'session' | 'duration'; + /** Epoch ms; required for `duration` kind. */ expiresAt?: number; grantedAt: string; /** Provenance — `tty` now, `phone`/`app` later. */ @@ -39,8 +41,8 @@ export function getGrantFilePath(sessionUuid: string): string { } function isGrantValid(grant: ApprovalGrant, now: number): boolean { - if (grant.scope === 'session') return true; - if (grant.scope === 'duration') return typeof grant.expiresAt === 'number' && now < grant.expiresAt; + if (grant.kind === 'session') return true; + if (grant.kind === 'duration') return typeof grant.expiresAt === 'number' && now < grant.expiresAt; return false; } @@ -72,9 +74,10 @@ export function createApprovalGrantStore(sessionUuid: string) { const full: ApprovalGrant = { id: grant.id ?? randomBytes(8).toString('hex'), grantedAt: grant.grantedAt ?? new Date().toISOString(), + grantKey: grant.grantKey, ruleId: grant.ruleId, host: grant.host, - scope: grant.scope, + kind: grant.kind, grantedBy: grant.grantedBy, ...(grant.expiresAt !== undefined ? { expiresAt: grant.expiresAt } : {}), }; @@ -86,10 +89,10 @@ export function createApprovalGrantStore(sessionUuid: string) { } return full; }, - /** The first still-valid grant covering `ruleId`, or undefined. */ - async findMatch(ruleId: string): Promise { + /** The first still-valid grant for `grantKey`, or undefined. */ + async findMatch(grantKey: string): Promise { const now = Date.now(); - return (await list()).find((g) => g.ruleId === ruleId && isGrantValid(g, now)); + return (await list()).find((g) => g.grantKey === grantKey && isGrantValid(g, now)); }, /** Remove the session's grant file (called on session stop). */ async destroy(): Promise { @@ -102,9 +105,12 @@ export type ApprovalGrantStore = ReturnType; /** * Wrap a base approval provider (TTY now, phone later) with a standing-grant - * store: honor an existing grant for the request's rule without prompting, and - * persist a new grant when the approver chooses a `session`/`duration` scope. - * This is the seam where remembering decisions is decoupled from making them. + * store: honor an existing grant for the request's grant key without prompting, + * and persist a new grant when the approver chooses a lifetime beyond `once`. + * The chosen lifetime is **clamped to the rule's `maxDuration`** before storing, + * so no approver can exceed the schema-set ceiling (`maxDurationMs===0` ⇒ never + * remembered — always ask). This is the seam where remembering decisions is + * decoupled from making them. */ export function createGrantingApprovalProvider(opts: { inner: ApprovalProvider; @@ -113,30 +119,35 @@ export function createGrantingApprovalProvider(opts: { }): ApprovalProvider { return { async requestApproval(req) { - if (req.ruleId) { - const grant = await opts.store.findMatch(req.ruleId); + // Always-ask rules (maxDurationMs===0) never honor or store grants. + const grantsAllowed = req.grantKey !== undefined && req.maxDurationMs !== 0; + + if (grantsAllowed && req.grantKey) { + const grant = await opts.store.findMatch(req.grantKey); if (grant) { return { approved: true, nonce: req.nonce, - scope: { kind: 'once' }, // already persisted; don't re-store - reason: `auto-approved by ${grant.scope} grant (${grant.grantedBy})`, + lifetime: { kind: 'once' }, // already persisted; don't re-store + reason: `auto-approved by ${grant.kind} grant (${grant.grantedBy})`, }; } } const decision = await opts.inner.requestApproval(req); - if (decision.approved && req.ruleId && decision.scope && decision.scope.kind !== 'once') { - await opts.store.add({ - ruleId: req.ruleId, - host: req.host, - scope: decision.scope.kind === 'session' ? 'session' : 'duration', - grantedBy: opts.grantedBy ?? 'tty', - ...(decision.scope.kind === 'duration' - ? { expiresAt: Date.now() + decision.scope.durationMs } - : {}), - }); + if (decision.approved && grantsAllowed && req.grantKey) { + const lifetime = clampLifetime(decision.lifetime, req.maxDurationMs); + if (lifetime.kind !== 'once') { + await opts.store.add({ + grantKey: req.grantKey, + ruleId: req.ruleId ?? '', + host: req.host, + kind: lifetime.kind === 'session' ? 'session' : 'duration', + grantedBy: opts.grantedBy ?? 'tty', + ...(lifetime.kind === 'duration' ? { expiresAt: Date.now() + lifetime.durationMs } : {}), + }); + } } return decision; diff --git a/packages/varlock/src/proxy/approval.test.ts b/packages/varlock/src/proxy/approval.test.ts index e561e62fb..5b99c81bf 100644 --- a/packages/varlock/src/proxy/approval.test.ts +++ b/packages/varlock/src/proxy/approval.test.ts @@ -2,6 +2,7 @@ import { PassThrough } from 'node:stream'; import { describe, expect, test } from 'vitest'; import { + clampLifetime, createApprovalRequest, createAutoDenyApprovalProvider, createTtyApprovalProvider, @@ -48,6 +49,21 @@ describe('isApprovalValid', () => { }); }); +describe('clampLifetime (schema ceiling)', () => { + test('maxDuration=0 forces once (always ask)', () => { + expect(clampLifetime({ kind: 'session' }, 0)).toEqual({ kind: 'once' }); + expect(clampLifetime({ kind: 'duration', durationMs: 60_000 }, 0)).toEqual({ kind: 'once' }); + }); + test('no cap (undefined) leaves the lifetime untouched', () => { + expect(clampLifetime({ kind: 'session' }, undefined)).toEqual({ kind: 'session' }); + }); + test('a finite cap turns session into a bounded duration and shortens longer ones', () => { + expect(clampLifetime({ kind: 'session' }, 60_000)).toEqual({ kind: 'duration', durationMs: 60_000 }); + expect(clampLifetime({ kind: 'duration', durationMs: 120_000 }, 60_000)).toEqual({ kind: 'duration', durationMs: 60_000 }); + expect(clampLifetime({ kind: 'duration', durationMs: 30_000 }, 60_000)).toEqual({ kind: 'duration', durationMs: 30_000 }); + }); +}); + describe('createAutoDenyApprovalProvider', () => { test('always denies, echoing the nonce', async () => { const r = req(); @@ -73,7 +89,7 @@ describe('createTtyApprovalProvider', () => { input.write('y\n'); const decision = await pending; expect(decision.approved).toBe(true); - expect(decision.scope).toEqual({ kind: 'once' }); + expect(decision.lifetime).toEqual({ kind: 'once' }); expect(isApprovalValid(r, decision)).toBe(true); }); @@ -82,7 +98,7 @@ describe('createTtyApprovalProvider', () => { const sProvider = createTtyApprovalProvider({ input: sInput, output: new PassThrough() }); const sPending = sProvider.requestApproval(req()); sInput.write('s\n'); - expect((await sPending).scope).toEqual({ kind: 'session' }); + expect((await sPending).lifetime).toEqual({ kind: 'session' }); const mInput = ttyInput(); const mProvider = createTtyApprovalProvider({ input: mInput, output: new PassThrough() }); @@ -90,7 +106,7 @@ describe('createTtyApprovalProvider', () => { mInput.write('m\n'); const mDecision = await mPending; expect(mDecision.approved).toBe(true); - expect(mDecision.scope).toMatchObject({ kind: 'duration' }); + expect(mDecision.lifetime).toMatchObject({ kind: 'duration' }); }); test('denies on "n" (and anything that is not yes)', async () => { @@ -118,6 +134,23 @@ describe('createTtyApprovalProvider', () => { expect(prompt).toContain('STRIPE_KEY'); // key name shown }); + test('always-ask (maxDurationMs=0) offers only once/no, and "s"/"m" are denied', async () => { + const input = ttyInput(); + const output = new PassThrough(); + let prompt = ''; + output.on('data', (c: Buffer) => { + prompt += c.toString('utf8'); + }); + const provider = createTtyApprovalProvider({ input, output }); + const pending = provider.requestApproval(req({ maxDurationMs: 0 })); + input.write('s\n'); // session not offered → should be denied + const decision = await pending; + expect(prompt).toContain('[y] once'); + expect(prompt).not.toContain('this session'); + expect(prompt).not.toContain('min'); + expect(decision.approved).toBe(false); + }); + test('fails closed when there is no TTY', async () => { const provider = createTtyApprovalProvider({ input: new PassThrough(), output: new PassThrough() }); expect((await provider.requestApproval(req())).approved).toBe(false); diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts index 919b1cbb0..1b87484f8 100644 --- a/packages/varlock/src/proxy/approval.ts +++ b/packages/varlock/src/proxy/approval.ts @@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto'; import readline from 'node:readline'; import type { Readable, Writable } from 'node:stream'; +import type { ProxyApprovalEach } from './types'; + /** * A request-bound approval request (Invariant #8). It commits to the EXACT * request — method + verified host + path + body hash + a nonce + an expiry — @@ -23,16 +25,27 @@ export type ApprovalRequest = { /** Epoch ms; the provider must decide before this. */ expiresAt: number; ruleId?: string; + /** + * Key a standing grant is stored/matched under — `ruleId` plus the rule's + * granularity (`each`). Absent ⇒ this request can't be remembered. + */ + grantKey?: string; + /** + * Schema-enforced ceiling on how long a "yes" may be remembered, in ms. + * `0` = always ask; `undefined` = up to the whole session. + */ + maxDurationMs?: number; /** Secret keys (names, never values) that WOULD be injected if approved. */ injectedKeys?: Array; }; /** * How long an approval lasts. `once` (default) authorizes only this request; the - * others authorize future requests matching the same rule (a standing grant — - * see approval-grants.ts) until the session ends or the window elapses. + * others authorize future requests matching the same grant key (see + * approval-grants.ts) until the session ends or the window elapses. (Granularity + * — *which* requests — is a separate axis, captured by the grant key.) */ -export type ApprovalScope = | { kind: 'once' } +export type ApprovalLifetime = | { kind: 'once' } | { kind: 'session' } | { kind: 'duration'; durationMs: number }; @@ -41,7 +54,7 @@ export type ApprovalDecision = { /** Echoes the request nonce the approver acted on; must match to be honored. */ nonce: string; /** How long this approval lasts. Absent ⇒ `once`. */ - scope?: ApprovalScope; + lifetime?: ApprovalLifetime; reason?: string; }; @@ -59,6 +72,30 @@ export function hashBody(body: Buffer | string): string { const DEFAULT_TTL_MS = 60_000; +/** + * The grant key for a request: `ruleId` plus the part of the request that the + * rule's granularity (`each`) distinguishes. So a broad rule can still yield + * fine-grained grants (per-endpoint or per-exact-request) without many rules. + */ +function computeGrantKey(input: { + ruleId: string; + each: ProxyApprovalEach; + method: string; + host: string; + path: string; + bodyHash: string; +}): string { + let eachPart: string; + if (input.each === 'host') { + eachPart = input.host; + } else if (input.each === 'request') { + eachPart = `${input.method} ${input.host}${input.path} ${input.bodyHash}`; + } else { + eachPart = `${input.method} ${input.host}${input.path}`; // endpoint (default) + } + return `${input.ruleId} ${eachPart}`; +} + export function createApprovalRequest(input: { method: string; host: string; @@ -66,16 +103,31 @@ export function createApprovalRequest(input: { body: Buffer | string; ttlMs?: number; ruleId?: string; + each?: ProxyApprovalEach; + maxDurationMs?: number; injectedKeys?: Array; }): ApprovalRequest { + const bodyHash = hashBody(input.body); + const grantKey = input.ruleId + ? computeGrantKey({ + ruleId: input.ruleId, + each: input.each ?? 'endpoint', + method: input.method, + host: input.host, + path: input.path, + bodyHash, + }) + : undefined; return { method: input.method, host: input.host, path: input.path, - bodyHash: hashBody(input.body), + bodyHash, nonce: randomBytes(16).toString('hex'), expiresAt: Date.now() + (input.ttlMs ?? DEFAULT_TTL_MS), ...(input.ruleId ? { ruleId: input.ruleId } : {}), + ...(grantKey ? { grantKey } : {}), + ...(input.maxDurationMs !== undefined ? { maxDurationMs: input.maxDurationMs } : {}), ...(input.injectedKeys?.length ? { injectedKeys: input.injectedKeys } : {}), }; } @@ -89,6 +141,24 @@ export function isApprovalValid(req: ApprovalRequest, decision: ApprovalDecision return decision.approved && decision.nonce === req.nonce && Date.now() < req.expiresAt; } +/** + * Clamp a decision's lifetime to the rule's `maxDurationMs` ceiling — the + * schema-enforced cap. `0` ⇒ always once (never remembered); `undefined` ⇒ no + * cap; a finite cap turns `session` into a bounded duration and shortens any + * longer duration. This runs proxy-side so no approver (local or remote) can + * exceed what the schema allows. + */ +export function clampLifetime( + lifetime: ApprovalLifetime | undefined, + maxDurationMs: number | undefined, +): ApprovalLifetime { + if (!lifetime || lifetime.kind === 'once') return { kind: 'once' }; + if (maxDurationMs === 0) return { kind: 'once' }; + if (maxDurationMs === undefined) return lifetime; + if (lifetime.kind === 'session') return { kind: 'duration', durationMs: maxDurationMs }; + return { kind: 'duration', durationMs: Math.min(lifetime.durationMs, maxDurationMs) }; +} + /** Always denies — the safe default when no interactive approver is available. */ export function createAutoDenyApprovalProvider(): ApprovalProvider { return { @@ -98,26 +168,29 @@ export function createAutoDenyApprovalProvider(): ApprovalProvider { }; } -/** Default window for a "for a while" (`m`) terminal approval. */ +/** Default window for a "for a while" (`m`) terminal approval when the rule sets no cap. */ export const DEFAULT_GRANT_DURATION_MS = 15 * 60_000; -/** Maps a single-key terminal answer to a decision + scope. Anything not an explicit yes denies. */ -function parseTtyAnswer(answer: string, nonce: string): ApprovalDecision { +function formatMinutes(ms: number): string { + const mins = Math.max(1, Math.round(ms / 60_000)); + return mins >= 60 && mins % 60 === 0 ? `${mins / 60} hr` : `${mins} min`; +} + +/** Maps a single-key terminal answer to a decision, honoring which options were offered. */ +function parseTtyAnswer( + answer: string, + nonce: string, + opts: { allowSession: boolean; durationMs?: number }, +): ApprovalDecision { const a = answer.trim().toLowerCase(); if (a === 'y' || a === 'yes' || a === 'o') { - return { - approved: true, nonce, scope: { kind: 'once' }, reason: 'approved once at terminal', - }; + return { approved: true, nonce, lifetime: { kind: 'once' } }; } - if (a === 's') { - return { - approved: true, nonce, scope: { kind: 'session' }, reason: 'approved for session at terminal', - }; + if (a === 's' && opts.allowSession) { + return { approved: true, nonce, lifetime: { kind: 'session' } }; } - if (a === 'm') { - return { - approved: true, nonce, scope: { kind: 'duration', durationMs: DEFAULT_GRANT_DURATION_MS }, reason: 'approved for a window at terminal', - }; + if (a === 'm' && opts.durationMs !== undefined) { + return { approved: true, nonce, lifetime: { kind: 'duration', durationMs: opts.durationMs } }; } return { approved: false, nonce, reason: 'denied at terminal' }; } @@ -125,8 +198,9 @@ function parseTtyAnswer(answer: string, nonce: string): ApprovalDecision { /** * Prompts for approval on the proxy process's controlling terminal. Suited to * `varlock proxy start`, where the agent runs elsewhere and the proxy owns the - * TTY. Offers scope choices (once / session / window) and fails closed: denies on - * a non-TTY, timeout, EOF, or any answer other than an explicit yes. + * TTY. The offered options adapt to the rule's `maxDurationMs` cap (always-ask + * shows only once). Fails closed: denies on a non-TTY, timeout, EOF, or any + * answer other than an explicit yes. */ export function createTtyApprovalProvider(opts?: { input?: Readable & { isTTY?: boolean }; @@ -136,7 +210,6 @@ export function createTtyApprovalProvider(opts?: { const input = opts?.input ?? process.stdin; const output = opts?.output ?? process.stderr; const timeoutMs = opts?.timeoutMs ?? DEFAULT_TTL_MS; - const minutes = Math.round(DEFAULT_GRANT_DURATION_MS / 60_000); return { async requestApproval(req) { @@ -144,11 +217,22 @@ export function createTtyApprovalProvider(opts?: { return { approved: false, nonce: req.nonce, reason: 'no interactive terminal to prompt for approval' }; } + // Offer only what the rule's cap permits: maxDurationMs===0 → once only; + // undefined → session allowed; finite → a bounded window, no session. + const allowGrants = req.grantKey !== undefined && req.maxDurationMs !== 0; + const allowSession = allowGrants && req.maxDurationMs === undefined; + const durationMs = allowGrants ? (req.maxDurationMs ?? DEFAULT_GRANT_DURATION_MS) : undefined; + + const options = ['[y] once']; + if (allowSession) options.push('[s] this session'); + if (durationMs !== undefined) options.push(`[m] ${formatMinutes(durationMs)}`); + options.push('[n] no'); + const inj = req.injectedKeys?.length ? ` injecting [${req.injectedKeys.join(', ')}]` : ''; const prompt = '\n🔐 varlock proxy — approval required\n' + ` ${req.method} https://${req.host}${req.path}${inj}\n${ req.ruleId ? ` rule: ${req.ruleId}\n` : '' - } Approve? [y] once [s] this session [m] ${minutes} min [n] no `; + } Approve? ${options.join(' ')} `; const rl = readline.createInterface({ input, output }); const answer = await new Promise((resolve) => { @@ -168,7 +252,7 @@ export function createTtyApprovalProvider(opts?: { }); rl.close(); - return parseTtyAnswer(answer, req.nonce); + return parseTtyAnswer(answer, req.nonce, { allowSession, durationMs }); }, }; } diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts index bb96681e2..282e88822 100644 --- a/packages/varlock/src/proxy/policy.test.ts +++ b/packages/varlock/src/proxy/policy.test.ts @@ -51,7 +51,7 @@ describe('evaluateProxyPolicy', () => { }); test('an approve rule yields require-approval', () => { - const approveRules = [rule({ domain: ['api.x.com'], path: '/v1/refunds/**', approve: true })]; + const approveRules = [rule({ domain: ['api.x.com'], path: '/v1/refunds/**', approval: true })]; expect(evaluateProxyPolicy(facts('api.x.com', 'POST', '/v1/refunds/42'), approveRules).verdict).toBe('require-approval'); // a non-matching path falls through to allow expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/customers'), approveRules).verdict).toBe('allow'); @@ -60,7 +60,7 @@ describe('evaluateProxyPolicy', () => { test('block beats require-approval beats allow', () => { const layered = [ rule({ domain: ['api.x.com'] }), // broad allow - rule({ domain: ['api.x.com'], path: '/admin/**', approve: true }), + rule({ domain: ['api.x.com'], path: '/admin/**', approval: true }), rule({ domain: ['api.x.com'], path: '/admin/destroy', block: true }), ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/admin/destroy'), layered).verdict).toBe('deny'); @@ -70,7 +70,7 @@ describe('evaluateProxyPolicy', () => { test('a more-specific allow exempts a path from a broad approve', () => { const broadApprove = [ - rule({ domain: ['api.x.com'], approve: true }), // approve everything by default + rule({ domain: ['api.x.com'], approval: true }), // approve everything by default rule({ domain: ['api.x.com'], path: '/health' }), // ...except this safe path ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/anything'), broadApprove).verdict).toBe('require-approval'); @@ -80,7 +80,7 @@ describe('evaluateProxyPolicy', () => { test('on a specificity tie, require-approval wins over allow (more restrictive)', () => { const tied = [ rule({ domain: ['api.x.com'], path: '/v1/*' }), - rule({ domain: ['api.x.com'], path: '/v1/*', approve: true }), + rule({ domain: ['api.x.com'], path: '/v1/*', approval: true }), ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/x'), tied).verdict).toBe('require-approval'); }); diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts index 54e395c0c..620e159ed 100644 --- a/packages/varlock/src/proxy/policy.ts +++ b/packages/varlock/src/proxy/policy.ts @@ -75,22 +75,22 @@ export function describeRule(rule: ProxyRule): string { if (rule.method !== undefined) parts.push(rule.method.toUpperCase()); if (rule.path !== undefined) parts.push(rule.path); if (rule.block) parts.push('block'); - if (rule.approve) parts.push('approve'); + if (rule.approval) parts.push('approval'); return parts.join(' '); } /** * Evaluate the policy for a request. Precedence (most restrictive wins): * 1. any matching `block` rule → deny - * 2. else the most-specific tier of non-block rules decides: an `approve` rule + * 2. else the most-specific tier of non-block rules decides: an `approval` rule * in that tier → require-approval, otherwise allow * 3. no matching rule → allow (injection scoping is handled separately, so an * allow verdict doesn't imply a secret is injected) * * Tie-break is deliberately conservative: within the most-specific tier an - * `approve` rule beats a plain allow, so a broad allow can't silently downgrade a + * `approval` rule beats a plain allow, so a broad allow can't silently downgrade a * specific require-approval, and a specific allow can still exempt a safe path - * from a broad approve. + * from a broad approval. */ export function evaluateProxyPolicy(facts: RequestFacts, rules: Array): PolicyDecision { const matching = rules.filter((rule) => ruleMatchesFacts(rule, facts)); @@ -108,12 +108,12 @@ export function evaluateProxyPolicy(facts: RequestFacts, rules: Array } const maxSpecificity = Math.max(...nonBlock.map(ruleSpecificity)); const topTier = nonBlock.filter((rule) => ruleSpecificity(rule) === maxSpecificity); - const approveRule = topTier.find((rule) => rule.approve); - if (approveRule) { + const approvalRule = topTier.find((rule) => rule.approval); + if (approvalRule) { return { verdict: 'require-approval', - matchedRule: approveRule, - reason: 'requires approval per @proxy(approve=true) rule', + matchedRule: approvalRule, + reason: 'requires approval per @proxy(approval=...) rule', }; } return { verdict: 'allow', matchedRule: topTier[0] }; diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index ce02f93bb..304575337 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -283,7 +283,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approve: true, + source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approval: true, }, ], egressMode: 'permissive', @@ -322,7 +322,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approve: true, + source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approval: true, }, ], egressMode: 'permissive', diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index ea9f55f8f..8dff16c31 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -19,7 +19,9 @@ import { createEphemeralCa, createHostCert } from './cert-authority'; import { describeRule, evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, } from './policy'; -import type { ProxyEgressMode, ProxyManagedItem, ProxyRule } from './types'; +import type { + ProxyApprovalEach, ProxyEgressMode, ProxyManagedItem, ProxyRule, +} from './types'; const LOCALHOST = '127.0.0.1'; @@ -106,6 +108,8 @@ async function runApprovalGate(input: { path: string; body: Buffer; ruleId?: string; + each?: ProxyApprovalEach; + maxDurationMs?: number; injectedKeys: Array; }): Promise { if (!input.approvalProvider) return false; @@ -115,6 +119,8 @@ async function runApprovalGate(input: { path: input.path, body: input.body, ruleId: input.ruleId, + each: input.each, + maxDurationMs: input.maxDurationMs, injectedKeys: input.injectedKeys, }); try { @@ -450,7 +456,15 @@ export async function startLocalProxyRuntime({ // request-bound decision. Fail closed (deny) unless explicitly approved. if (policyDecision?.verdict === 'require-approval') { const approved = await runApprovalGate({ - approvalProvider, method, host: hostInfo.host, path: pathOnly, body, ruleId: ruleIdStr, injectedKeys, + approvalProvider, + method, + host: hostInfo.host, + path: pathOnly, + body, + ruleId: ruleIdStr, + each: policyDecision.matchedRule?.approvalEach, + maxDurationMs: policyDecision.matchedRule?.approvalMaxDurationMs, + injectedKeys, }); if (!approved) { onActivity?.({ @@ -633,7 +647,15 @@ export async function startLocalProxyRuntime({ // request-bound decision. Fail closed (deny) unless explicitly approved. if (policyDecision?.verdict === 'require-approval') { const approved = await runApprovalGate({ - approvalProvider, method, host: destination.hostname, path: pathOnly, body, ruleId: ruleIdStr, injectedKeys, + approvalProvider, + method, + host: destination.hostname, + path: pathOnly, + body, + ruleId: ruleIdStr, + each: policyDecision.matchedRule?.approvalEach, + maxDurationMs: policyDecision.matchedRule?.approvalMaxDurationMs, + injectedKeys, }); if (!approved) { onActivity?.({ diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index e17c143d5..4ed35dada 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -2,6 +2,14 @@ export type ProxyEgressMode = 'permissive' | 'strict'; export type ProxyRuleSource = 'attached' | 'detached'; +/** + * Approval granularity — what a single approval (and any standing grant) covers. + * `host` = the host; `endpoint` = method + path; `request` = method + path + body. + */ +export type ProxyApprovalEach = 'host' | 'endpoint' | 'request'; + +export const PROXY_APPROVAL_EACH_VALUES: ReadonlyArray = ['host', 'endpoint', 'request']; + export type ProxyRule = { source: ProxyRuleSource; domain: Array; @@ -10,7 +18,15 @@ export type ProxyRule = { method?: string; block?: boolean; /** Require out-of-band approval before this request is forwarded (Invariant #8). */ - approve?: boolean; + approval?: boolean; + /** Granularity of approvals / standing grants. Default `endpoint`. */ + approvalEach?: ProxyApprovalEach; + /** + * Ceiling on how long a "yes" may be remembered, in ms — the schema-enforced + * cap on grant lifetime. `0` = always ask (never remembered); `undefined` = + * may persist for the whole session. + */ + approvalMaxDurationMs?: number; }; export type ProxyManagedItem = { From d4f4b4e7b424e9ef01537d198933ca4a48684df2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 13 Jun 2026 21:35:43 -0700 Subject: [PATCH 17/64] feat(varlock): proxy schema fingerprint covers full definition (value-defs + decorators) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild buildProxySchemaFingerprint to hash per-item value definitions (pre-resolution) + all non-inert decorators + root decorators, canonical and location-independent. Add an 'inert' decorator flag (marks @example/@docs/@docsUrl/@icon/@deprecated) excluded from the fingerprint. Closes gaps the shape-only fingerprint missed (proxied→passthrough flip, domain/egress changes). Prereq for proxy run --session attach. --- .bumpy/proxy-schema-fingerprint-full.md | 7 ++ .../cli/helpers/proxy-schema-fingerprint.ts | 73 ++++++++++++++---- .../test/proxy-schema-fingerprint.test.ts | 75 +++++++++++++++++++ .../varlock/src/env-graph/lib/decorators.ts | 13 ++++ 4 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 .bumpy/proxy-schema-fingerprint-full.md create mode 100644 packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts diff --git a/.bumpy/proxy-schema-fingerprint-full.md b/.bumpy/proxy-schema-fingerprint-full.md new file mode 100644 index 000000000..861e92148 --- /dev/null +++ b/.bumpy/proxy-schema-fingerprint-full.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy: the schema fingerprint now covers the full schema definition. + +The fingerprint that guards an active proxy session against schema drift (and that `varlock proxy refresh` re-approves) now hashes each config item's value definitions (pre-resolution — no secrets, no I/O) plus every decorator, and all root decorators — instead of only key/sensitivity/required/type. Cosmetic decorators (`@example`, `@docs`, `@docsUrl`, `@icon`, `@deprecated`) are marked `inert` and excluded, and decorator order, named-arg order, comments, and whitespace don't affect it. This closes gaps where a behavioral change left the fingerprint unchanged — e.g. flipping a secret from `@proxy(domain=…)` to `@proxy=passthrough`, changing a `@proxy` domain, or the egress mode. diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index dff0e5937..bc0f342b3 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -1,5 +1,12 @@ import { createHash } from 'node:crypto'; +import { + ParsedEnvSpecFunctionArgs, + ParsedEnvSpecFunctionCall, + ParsedEnvSpecKeyValuePair, + ParsedEnvSpecStaticValue, +} from '@env-spec/parser'; + import type { EnvGraph } from '../../env-graph'; import { CliExitError } from './exit-error'; import { getProxySessionByToken } from '../../proxy/session-registry'; @@ -11,27 +18,63 @@ import { } from '../../proxy/env-vars'; /** - * Build a deterministic fingerprint for schema-level safety controls. - * Phase 1 focuses on preventing sensitivity downgrades from taking effect - * mid-proxy-run, so we hash item keys + sensitivity/required/type metadata. + * Canonical, pre-resolution string for a parsed value/arg node. Hashes the + * *definition* (resolver expression, decorator args), never the resolved value — + * so no secrets, no I/O, deterministic. Named args are sorted so authoring order + * doesn't matter; positional args (e.g. `$REF`s) keep order. + */ +function canonicalParsed(node: unknown): string { + if (node === undefined || node === null) return '∅'; + if (node instanceof ParsedEnvSpecStaticValue) return JSON.stringify(node.value ?? null); + if (node instanceof ParsedEnvSpecKeyValuePair) return `${node.key}=${canonicalParsed(node.value)}`; + if (node instanceof ParsedEnvSpecFunctionCall) return `${node.name}${canonicalParsed(node.data.args)}`; + if (node instanceof ParsedEnvSpecFunctionArgs) { + const positional: Array = []; + const named: Array = []; + for (const val of node.values) { + if (val instanceof ParsedEnvSpecKeyValuePair) named.push(`${val.key}=${canonicalParsed(val.value)}`); + else positional.push(canonicalParsed(val)); + } + named.sort(); + return `(${[...positional, ...named].join(',')})`; + } + return '?'; +} + +function canonicalDecorator(dec: { name: string; parsedDecorator: { value?: unknown } }): string { + return `@${dec.name}=${canonicalParsed(dec.parsedDecorator.value)}`; +} + +/** + * Build a deterministic fingerprint of the schema *definition*: per config key, + * the value-source definitions (pre-resolution) plus every non-`inert` + * decorator, plus all non-`inert` root decorators (egress, detached `@proxy`, + * `@defaultSensitive`, …). Captures everything behavioral — proxy rules, + * sensitivity, types, placeholders, value sources — while ignoring cosmetic + * decorators (`@example`, `@docs`, `@icon`, …) and formatting. * - * Intentionally location-independent (no basePath): the security-relevant - * invariant is the schema *shape*, and including the base path would cause - * false-positive mismatches when a nested command resolves the schema from a - * different working directory than the session was started in. + * Intentionally location-independent (no basePath, only parsed expressions): a + * nested/attached command resolving the schema from a different cwd must produce + * the same fingerprint as the session that started the proxy. */ export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { - const schemaShape = envGraph.sortedConfigKeys.map((key) => { + const rootDecorators = envGraph.sortedDataSources + .flatMap((source) => source.rootDecorators) + .filter((dec) => !dec.isInert) + .map((dec) => canonicalDecorator(dec)) + .sort(); + + const items = envGraph.sortedConfigKeys.map((key) => { const item = envGraph.configSchema[key]; - return { - key, - isSensitive: item.isSensitive, - isRequired: item.isRequired, - dataType: item.dataType?.name ?? null, - }; + const valueDefs = item.defs.map((def) => canonicalParsed(def.itemDef.parsedValue)); + const decorators = item.allDecorators + .filter((dec) => !dec.isInert) + .map((dec) => canonicalDecorator(dec)) + .sort(); + return { key, valueDefs, decorators }; }); - const input = JSON.stringify({ schemaShape }); + const input = JSON.stringify({ rootDecorators, items }); return createHash('sha256').update(input).digest('hex'); } diff --git a/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts new file mode 100644 index 000000000..aaeef8f9f --- /dev/null +++ b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest'; +import outdent from 'outdent'; + +import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; +import { buildProxySchemaFingerprint } from '../proxy-schema-fingerprint'; + +async function fp(envFile: string): Promise { + const graph = new EnvGraph(); + await graph.setRootDataSource(new DotEnvFileDataSource('.env.schema', { overrideContents: envFile })); + await graph.finishLoad(); + await graph.resolveEnvValues(); + return buildProxySchemaFingerprint(graph); +} + +const BASE = outdent` + # @enableProxy(egress="strict") + # --- + # @sensitive @proxy(domain="api.x.com", approval=true) + SECRET=abc +`; + +describe('buildProxySchemaFingerprint', () => { + test('identical schemas → identical fingerprint', async () => { + expect(await fp(BASE)).toBe(await fp(BASE)); + }); + + test('decorator order, named-arg order, comments, and whitespace do not affect it', async () => { + const reordered = outdent` + # @enableProxy(egress="strict") + # --- + # a comment that should be ignored + # @proxy(approval=true, domain="api.x.com") @sensitive + SECRET=abc + `; + expect(await fp(reordered)).toBe(await fp(BASE)); + }); + + test('an inert decorator (@example) does not change it', async () => { + const withExample = outdent` + # @enableProxy(egress="strict") + # --- + # @sensitive @proxy(domain="api.x.com", approval=true) @example=placeholder-ish + SECRET=abc + `; + expect(await fp(withExample)).toBe(await fp(BASE)); + }); + + test('changing the @proxy domain changes it', async () => { + expect(await fp(BASE.replace('api.x.com', 'api.y.com'))).not.toBe(await fp(BASE)); + }); + + test('changing approval config changes it', async () => { + const capped = BASE.replace('approval=true', 'approval=true, approvalMaxDuration="15m"'); + expect(await fp(capped)).not.toBe(await fp(BASE)); + }); + + test('flipping proxied → passthrough changes it (the gap the old shape-only fingerprint missed)', async () => { + const passthrough = outdent` + # @enableProxy(egress="strict") + # --- + # @sensitive + # @proxy=passthrough + SECRET=abc + `; + expect(await fp(passthrough)).not.toBe(await fp(BASE)); + }); + + test('changing the value definition changes it', async () => { + expect(await fp(BASE.replace('SECRET=abc', 'SECRET=def'))).not.toBe(await fp(BASE)); + }); + + test('changing the egress mode (root decorator) changes it', async () => { + expect(await fp(BASE.replace('strict', 'permissive'))).not.toBe(await fp(BASE)); + }); +}); diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index c3bd44bbd..87493b4bf 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -59,6 +59,10 @@ export abstract class DecoratorInstance { get isFunctionOrValue() { return !!(this.decoratorDef as ItemDecoratorDef | undefined)?.isFunctionOrValue; } + /** Purely informational — excluded from the schema fingerprint (see `inert`). */ + get isInert() { + return !!this.decoratorDef?.inert; + } private processed = false; private processedData: any; @@ -291,6 +295,8 @@ export type RootDecoratorDef = { name: string, description?: string; isFunction?: boolean; + /** Purely informational (no effect on resolved values/behavior) → excluded from the schema fingerprint. */ + inert?: boolean; deprecated?: boolean | string; incompatibleWith?: Array; process?: (decoratorValue: Resolver) => (Processed | Promise); @@ -545,6 +551,8 @@ export type ItemDecoratorDef = { name: string, incompatibleWith?: Array; isFunction?: boolean; + /** Purely informational (no effect on resolved values/behavior) → excluded from the schema fingerprint. */ + inert?: boolean; /** * Allow BOTH the function form (`@name(...)`) and the value form (`@name=x`). * The two forms are mutually exclusive on a single item (see ConfigItem.process). @@ -583,20 +591,25 @@ export const builtInItemDecorators: Array> = [ }, { name: 'example', + inert: true, }, { name: 'docsUrl', deprecated: 'use `docs()` instead', + inert: true, }, { name: 'docs', isFunction: true, + inert: true, }, { name: 'icon', + inert: true, }, { name: 'deprecated', + inert: true, }, { name: 'auditIgnore', From 3a4c1ba63c2bada744e1e2f943d6a517e44f5145 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 13 Jun 2026 21:44:45 -0700 Subject: [PATCH 18/64] feat(varlock): proxy run attaches to a running proxy (--session / auto / --new) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy run now attaches to a proxy start daemon for the current dir (single cwd-match, or --session ) instead of always spawning a fresh auto-deny proxy — so the daemon's terminal handles approvals. Validates the schema fingerprint and fails loudly on drift; --new forces a fresh proxy. Reuses the session's env + placeholders; no new runtime/approver in attach mode. --- .bumpy/proxy-run-attach.md | 7 + .../varlock/src/cli/commands/proxy.command.ts | 132 ++++++++++++++---- .../cli/commands/test/proxy.command.test.ts | 12 +- 3 files changed, 124 insertions(+), 27 deletions(-) create mode 100644 .bumpy/proxy-run-attach.md diff --git a/.bumpy/proxy-run-attach.md b/.bumpy/proxy-run-attach.md new file mode 100644 index 000000000..0e2952182 --- /dev/null +++ b/.bumpy/proxy-run-attach.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`varlock proxy run` attaches to a running proxy when possible. + +Instead of always starting a fresh (auto-deny) proxy, `proxy run` now attaches to a `proxy start` daemon for the current directory — so the daemon's terminal handles approval prompts while you run the agent in another. It picks the single running session whose directory contains yours (or use `--session `), validates the schema fingerprint (and tells you to restart the proxy on drift, rather than silently routing through a stale one), and injects the session's proxy env + placeholders. Pass `--new` to force a separate fresh proxy. This is the missing piece that makes interactive approval usable from a `run`-style agent command. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 49964357c..ba8fec7f5 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { define } from 'gunshi'; import { gracefulExit } from 'exit-hook'; @@ -88,10 +90,16 @@ export const commandSpec = define({ short: 'i', description: 'Control what gets injected into child env for `proxy run`: "all" (default), "vars", or "blob"', }, + new: { + type: 'boolean', + description: 'For `proxy run`: start a fresh proxy instead of attaching to a running one for this directory', + }, }, examples: ` Proxy command surface: - varlock proxy run -- claude + varlock proxy run -- claude # attaches to a running proxy for this dir, else starts one + varlock proxy run --session abc12 -- claude # attach to a specific session (approvals prompt in its terminal) + varlock proxy run --new -- claude # force a fresh, separate proxy varlock proxy start varlock proxy env --session abc12 varlock proxy status @@ -448,6 +456,57 @@ async function collectStatusSessions(ctx: any): Promise { + await cleanupStaleProxySessions(); + + let candidate: ProxySessionRecord | undefined; + if (ctx.values.session) { + candidate = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: false, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + } else { + const cwd = process.cwd(); + const matches = (await listProxySessions()).filter((s) => isCwdWithin(cwd, s.cwd)); + if (matches.length === 0) return undefined; // → start a fresh proxy + if (matches.length > 1) { + throw new CliExitError( + `Multiple proxy sessions match this directory: ${matches.map((s) => s.id).join(', ')}.`, + { suggestion: 'Pass --session to choose one, or --new to start a separate proxy.' }, + ); + } + [candidate] = matches; + } + + if (candidate.schemaFingerprint && candidate.schemaFingerprint !== schemaFingerprint) { + throw new CliExitError( + `The running proxy session ${candidate.id} was started with a different .env.schema.`, + { + details: "Its placeholders and rules no longer match this directory's schema.", + suggestion: 'Restart it (or run `varlock proxy refresh`), or use --new to start a separate proxy.', + }, + ); + } + return candidate; +} + async function runAction(ctx: any) { const commandToRunAsArgs = getRunCommandArgs(); const rawCommand = commandToRunAsArgs[0]!; @@ -455,18 +514,47 @@ async function runAction(ctx: any) { const commandToRunStr = commandToRunAsArgs.join(' '); const policy = await prepareProxyPolicy(ctx.values.path); - const { - runtime, session, statsWriter, auditLog, - } = await createRuntimeAndSession({ - policy, - entryPaths: ctx.values.path, - command: commandToRunAsArgs, - // The child owns this terminal's stdio, so we can't safely prompt here — - // require-approval requests fail closed. Use `proxy start` for interactive - // approval (the proxy owns the terminal there). - approvalProvider: createAutoDenyApprovalProvider(), - }); - console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); + + // Attach to a running proxy for this directory (a `proxy start` daemon) when + // possible — its terminal handles approvals — instead of a new auto-deny proxy. + // --new forces a fresh proxy. + const attachSession = ctx.values.new ? undefined : await resolveAttachSession(ctx, policy.schemaFingerprint); + + let session: ProxySessionRecord; + let statsWriter: ReturnType | undefined; + let cleanup: () => Promise; + + if (attachSession) { + // Use the daemon's authoritative placeholders so the child's values are + // exactly what the running proxy expects to substitute. + for (const [key, placeholder] of Object.entries(attachSession.placeholderOverrides ?? {})) { + policy.resolvedEnv[key] = placeholder; + if (policy.serializedGraph.config[key]) policy.serializedGraph.config[key].value = placeholder; + } + session = attachSession; + console.error(`Attached to proxy session ${session.id}. Approval prompts (if any) appear in its terminal.`); + cleanup = async () => undefined; // nothing to tear down — the session is the daemon's + } else { + const created = await createRuntimeAndSession({ + policy, + entryPaths: ctx.values.path, + command: commandToRunAsArgs, + // The child owns this terminal's stdio, so we can't safely prompt here — + // require-approval requests fail closed. Use `proxy start` (or attach to one) + // for interactive approval. + approvalProvider: createAutoDenyApprovalProvider(), + }); + session = created.session; + statsWriter = created.statsWriter; + console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); + cleanup = async () => { + await created.statsWriter.flushNow(); + created.statsWriter.stop(); + await created.runtime.stop().catch(() => undefined); + await created.auditLog.flush().catch(() => undefined); + await deleteProxySessionRecord(session.uuid).catch(() => undefined); + }; + } const { injectVars, injectBlob } = applyInjectModeFromFlags(ctx); const sessionEnv = getProxySessionExportEnv(session); @@ -484,16 +572,6 @@ async function runAction(ctx: any) { } let commandProcess: ReturnType | undefined; - let cleanedUp = false; - const cleanup = async () => { - if (cleanedUp) return; - cleanedUp = true; - await statsWriter.flushNow(); - statsWriter.stop(); - await runtime.stop().catch(() => undefined); - await auditLog.flush().catch(() => undefined); - await deleteProxySessionRecord(session.uuid).catch(() => undefined); - }; const redactLogs = policy.serializedGraph.settings?.redactLogs ?? true; const noRedactStdout = ctx.values['no-redact-stdout'] ?? false; @@ -553,7 +631,7 @@ async function runAction(ctx: any) { commandProcess.stderr?.on('data', (chunk: Buffer | string) => writeRedacted(process.stderr, chunk)); } - if (commandProcess.pid) { + if (commandProcess.pid && !attachSession) { await updateProxySessionRecord(session.uuid, { childPid: commandProcess.pid }); } @@ -584,8 +662,10 @@ async function runAction(ctx: any) { } } finally { await cleanup(); - const stats = statsWriter.stats; - console.error(`Proxy session ${session.id} summary: req=${stats.totalRequests} matched=${stats.matchedRequests} blocked=${stats.blockedRequests}`); + if (statsWriter) { + const stats = statsWriter.stats; + console.error(`Proxy session ${session.id} summary: req=${stats.totalRequests} matched=${stats.matchedRequests} blocked=${stats.blockedRequests}`); + } } return gracefulExit(exitCode); diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index 8d551a476..f405d2ff4 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; -import { getProxyOmittedKeys } from '../proxy.command.js'; +import { getProxyOmittedKeys, isCwdWithin } from '../proxy.command.js'; async function loadGraph(envFile: string) { const graph = new EnvGraph(); @@ -92,3 +92,13 @@ describe('getProxyOmittedKeys', () => { expect(await omittedKeys(graph)).toEqual([]); }); }); + +describe('isCwdWithin (proxy run attach matching)', () => { + test('matches the same dir and subdirectories, not siblings or ancestors', () => { + expect(isCwdWithin('/a/b', '/a/b')).toBe(true); // same + expect(isCwdWithin('/a/b/c', '/a/b')).toBe(true); // subdir + expect(isCwdWithin('/a', '/a/b')).toBe(false); // ancestor + expect(isCwdWithin('/a/bcd', '/a/b')).toBe(false); // sibling sharing a prefix + expect(isCwdWithin('/x/y', '/a/b')).toBe(false); // unrelated + }); +}); From c88c93075ffd321fb06f826a8d535e7da0b5e685 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sat, 13 Jun 2026 22:16:46 -0700 Subject: [PATCH 19/64] =?UTF-8?q?feat(varlock):=20session-as-record=20reor?= =?UTF-8?q?g=20=E2=80=94=20co-locate=20per-session=20files,=20keep=20on=20?= =?UTF-8?q?stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpy/proxy-session-record.md | 7 ++ .../varlock/src/cli/commands/proxy.command.ts | 28 +++--- .../varlock/src/proxy/approval-grants.test.ts | 10 +- packages/varlock/src/proxy/approval-grants.ts | 29 ++---- packages/varlock/src/proxy/audit.test.ts | 2 +- packages/varlock/src/proxy/audit.ts | 15 ++- .../src/proxy/session-registry.test.ts | 92 +++++++++++++++++++ .../varlock/src/proxy/session-registry.ts | 83 +++++++++++++---- 8 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 .bumpy/proxy-session-record.md create mode 100644 packages/varlock/src/proxy/session-registry.test.ts diff --git a/.bumpy/proxy-session-record.md b/.bumpy/proxy-session-record.md new file mode 100644 index 000000000..ff61254cf --- /dev/null +++ b/.bumpy/proxy-session-record.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy sessions are kept as a durable record instead of deleted on stop. + +Each session now lives in its own directory (`proxy/sessions//`) holding its `session.json`, `audit.jsonl`, and `grants.jsonl` together. Stopping a session marks it ended rather than deleting it, so its audit log and approval grants survive for later inspection. `proxy status` shows active sessions by default; pass `--all` to include ended ones. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index ba8fec7f5..6bbcce292 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -25,7 +25,7 @@ import { import { cleanupStaleProxySessions, createProxySessionRecord, - deleteProxySessionRecord, + markProxySessionEnded, getProxySessionExportEnv, listProxySessions, reserveProxySessionIdentity, @@ -423,9 +423,10 @@ function formatSessionStatus(session: ProxySessionRecord): string { const child = session.childPid ? ` child=${session.childPid}` : ''; const stats = session.stats ?? EMPTY_PROXY_SESSION_STATS; const last = stats.lastActivityAt ? ` last=${stats.lastActivityAt}` : ''; + const state = session.endedAt ? ` ended=${session.endedAt}` : ''; return `[${session.id}] ${session.uuid} pid=${session.ownerPid}${child} egress=${session.egressMode} ` + `cwd=${session.cwd} req=${stats.totalRequests} matched=${stats.matchedRequests} ` - + `blocked=${stats.blockedRequests}${last}`; + + `blocked=${stats.blockedRequests}${last}${state}`; } function printSessionStatus(session: ProxySessionRecord) { @@ -453,7 +454,9 @@ async function collectStatusSessions(ctx: any): Promise undefined); await created.auditLog.flush().catch(() => undefined); - await deleteProxySessionRecord(session.uuid).catch(() => undefined); + await markProxySessionEnded(session.uuid).catch(() => undefined); }; } @@ -674,7 +677,7 @@ async function runAction(ctx: any) { async function startAction(ctx: any) { const policy = await prepareProxyPolicy(ctx.values.path); const { - runtime, session, statsWriter, auditLog, grantStore, + runtime, session, statsWriter, auditLog, } = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, @@ -697,8 +700,8 @@ async function startAction(ctx: any) { statsWriter.stop(); await runtime.stop().catch(() => undefined); await auditLog.flush().catch(() => undefined); - await grantStore?.destroy().catch(() => undefined); - await deleteProxySessionRecord(session.uuid).catch(() => undefined); + // Grants stay as part of the session's durable record — not destroyed on stop. + await markProxySessionEnded(session.uuid).catch(() => undefined); }; await new Promise((resolve) => { @@ -827,7 +830,7 @@ async function stopAction(ctx: any) { process.kill(session.ownerPid, 'SIGTERM'); console.log(`Sent SIGTERM to proxy session ${session.id} (pid ${session.ownerPid}).`); } catch { - await deleteProxySessionRecord(session.uuid); + await markProxySessionEnded(session.uuid); } } return; @@ -845,8 +848,8 @@ async function stopAction(ctx: any) { process.kill(session.ownerPid, 'SIGTERM'); console.log(`Sent SIGTERM to proxy session ${session.id} (pid ${session.ownerPid}).`); } catch { - await deleteProxySessionRecord(session.uuid); - console.log(`Removed stale proxy session ${session.id}.`); + await markProxySessionEnded(session.uuid); + console.log(`Marked stale proxy session ${session.id} ended.`); } } @@ -865,8 +868,9 @@ async function auditAction(ctx: any) { } // Resolve the session uuid. Prefer the live registry (accepts short ids and - // ancestry), but fall back to treating an explicit value as a uuid, since the - // audit log outlives the (deleted-on-stop) session record. + // ancestry), but fall back to treating an explicit value as a uuid, since an + // ended session is excluded from the active registry while its audit log + // remains in the session's directory. let uuid: string; try { const session = await resolveProxySessionForCommand({ diff --git a/packages/varlock/src/proxy/approval-grants.test.ts b/packages/varlock/src/proxy/approval-grants.test.ts index e36be3f23..30252b1b4 100644 --- a/packages/varlock/src/proxy/approval-grants.test.ts +++ b/packages/varlock/src/proxy/approval-grants.test.ts @@ -15,7 +15,7 @@ import { createApprovalRequest, type ApprovalLifetime, type ApprovalProvider, } from './approval'; -// Redirect the grants dir into a throwaway XDG_CONFIG_HOME (grantsDir resolves lazily). +// Redirect the grants dir into a throwaway XDG_CONFIG_HOME (the path resolves lazily). let tmpDir: string; let prevXdg: string | undefined; @@ -76,13 +76,13 @@ describe('approval grant store', () => { expect(await b.findMatch(KEY)).toBeUndefined(); }); - test('destroy removes the grant file', async () => { + test('grants live in the session directory as part of its durable record', async () => { const store = createApprovalGrantStore('sess-x'); await store.add(grant()); expect(existsSync(store.filePath)).toBe(true); - await store.destroy(); - expect(existsSync(store.filePath)).toBe(false); - expect(await store.findMatch(KEY)).toBeUndefined(); + // Co-located with the session record (proxy/sessions//grants.jsonl), not + // a separate grants/ tree — and not destroyed on stop. + expect(store.filePath).toMatch(/[/\\]proxy[/\\]sessions[/\\]sess-x[/\\]grants\.jsonl$/); }); }); diff --git a/packages/varlock/src/proxy/approval-grants.ts b/packages/varlock/src/proxy/approval-grants.ts index 70352a770..157e3b5a8 100644 --- a/packages/varlock/src/proxy/approval-grants.ts +++ b/packages/varlock/src/proxy/approval-grants.ts @@ -1,20 +1,19 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { - appendFile, mkdir, readFile, rm, -} from 'node:fs/promises'; -import { join } from 'node:path'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; -import { getUserVarlockDir } from '../lib/user-config-dir'; +import { getProxySessionDir } from './session-registry'; import { clampLifetime, type ApprovalProvider } from './approval'; /** * A standing approval grant: the approver's choice to extend an interactive * approval to *future* requests with the same grant key, for a lifetime. Holds - * no secret values. Stored per session so it can't outlive its session and - * cleans up trivially. The file is the source of truth (read on each - * require-approval), which is what lets an external approver (the future phone / - * native app) write a grant the proxy immediately honors. + * no secret values. Stored in the session's directory as part of its durable + * record (session-bound, so a grant can't be honored once the session is gone). + * The file is the source of truth (read on each require-approval), which is what + * lets an external approver (the future phone / native app) write a grant the + * proxy immediately honors. */ export type ApprovalGrant = { id: string; @@ -32,12 +31,8 @@ export type ApprovalGrant = { grantedBy: string; }; -function grantsDir(): string { - return join(getUserVarlockDir(), 'proxy', 'grants'); -} - export function getGrantFilePath(sessionUuid: string): string { - return join(grantsDir(), `${sessionUuid}.jsonl`); + return join(getProxySessionDir(sessionUuid), 'grants.jsonl'); } function isGrantValid(grant: ApprovalGrant, now: number): boolean { @@ -82,7 +77,7 @@ export function createApprovalGrantStore(sessionUuid: string) { ...(grant.expiresAt !== undefined ? { expiresAt: grant.expiresAt } : {}), }; try { - await mkdir(grantsDir(), { recursive: true, mode: 0o700 }); + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); await appendFile(filePath, `${JSON.stringify(full)}\n`, { mode: 0o600 }); } catch { // best-effort @@ -94,10 +89,6 @@ export function createApprovalGrantStore(sessionUuid: string) { const now = Date.now(); return (await list()).find((g) => g.grantKey === grantKey && isGrantValid(g, now)); }, - /** Remove the session's grant file (called on session stop). */ - async destroy(): Promise { - await rm(filePath, { force: true }).catch(() => undefined); - }, }; } diff --git a/packages/varlock/src/proxy/audit.test.ts b/packages/varlock/src/proxy/audit.test.ts index f85a15f74..e2574d5ef 100644 --- a/packages/varlock/src/proxy/audit.test.ts +++ b/packages/varlock/src/proxy/audit.test.ts @@ -19,7 +19,7 @@ import { import { startLocalProxyRuntime } from './runtime-proxy'; // Redirect the audit dir into a throwaway XDG_CONFIG_HOME so we never touch the -// real user config dir. getProxyAuditDir() resolves lazily, so this takes effect. +// real user config dir. The path resolves lazily, so this takes effect. let tmpDir: string; let prevXdg: string | undefined; diff --git a/packages/varlock/src/proxy/audit.ts b/packages/varlock/src/proxy/audit.ts index 41a794de7..44b46f685 100644 --- a/packages/varlock/src/proxy/audit.ts +++ b/packages/varlock/src/proxy/audit.ts @@ -1,9 +1,9 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { appendFile, mkdir, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; -import { getUserVarlockDir } from '../lib/user-config-dir'; +import { getProxySessionDir } from './session-registry'; /** The security decision the proxy reached for a single request. */ export type ProxyAuditDecision = | 'allow' // forwarded upstream (a secret may or may not have been injected) @@ -68,13 +68,10 @@ export type ProxyAuditEntry = { export type ProxyAuditLine = ProxyAuditHeader | ProxyAuditEntry; // Resolved lazily (not a module-load const) so it honors the active -// XDG_CONFIG_HOME / legacy-dir resolution at call time. -export function getProxyAuditDir(): string { - return join(getUserVarlockDir(), 'proxy', 'audit'); -} - +// XDG_CONFIG_HOME / legacy-dir resolution at call time. Co-located in the +// session's directory so a session's audit travels with its record. export function getProxyAuditFilePath(uuid: string): string { - return join(getProxyAuditDir(), `${uuid}.jsonl`); + return join(getProxySessionDir(uuid), 'audit.jsonl'); } /** Stable request fingerprint. Inputs are placeholder-form, so this hashes no secret. */ @@ -114,7 +111,7 @@ export function createProxyAuditLog(uuid: string, header?: Omit { if (disabled) return; try { - await mkdir(getProxyAuditDir(), { recursive: true, mode: 0o700 }); + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); await appendFile(filePath, `${JSON.stringify(line)}\n`, { mode: 0o600 }); } catch { disabled = true; // never let audit I/O break the proxy diff --git a/packages/varlock/src/proxy/session-registry.test.ts b/packages/varlock/src/proxy/session-registry.test.ts new file mode 100644 index 000000000..4ee2dd6f4 --- /dev/null +++ b/packages/varlock/src/proxy/session-registry.test.ts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; + +import { + createProxySessionRecord, + getProxySessionDir, + listProxySessions, + markProxySessionEnded, + type ProxySessionRecord, +} from './session-registry'; + +// Redirect the sessions dir into a throwaway XDG_CONFIG_HOME so we never touch +// the real user config dir. Paths resolve lazily, so this takes effect. +let tmpDir: string; +let prevXdg: string | undefined; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'varlock-session-test-')); + prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmpDir; +}); + +afterEach(async () => { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + await rm(tmpDir, { recursive: true, force: true }); +}); + +function record(overrides: Partial = {}): Omit { + return { + id: 'abc12', + uuid: 'uuid-1', + // Use this test process's pid so the session counts as "running". + ownerPid: process.pid, + cwd: '/tmp/project', + startedAt: '2026-06-13T00:00:00.000Z', + egressMode: 'permissive', + env: { FOO: 'bar' }, + ...overrides, + }; +} + +describe('proxy session registry (session-as-record)', () => { + test('creates a per-session directory holding session.json', async () => { + await createProxySessionRecord(record()); + expect(existsSync(join(getProxySessionDir('uuid-1'), 'session.json'))).toBe(true); + }); + + test('listProxySessions returns only active sessions by default', async () => { + await createProxySessionRecord(record()); + const active = await listProxySessions(); + expect(active.map((s) => s.uuid)).toEqual(['uuid-1']); + }); + + test('markProxySessionEnded excludes from active but keeps the durable record', async () => { + await createProxySessionRecord(record()); + // Drop a co-located file to prove the whole directory survives. + await writeFile(join(getProxySessionDir('uuid-1'), 'grants.jsonl'), '{}\n'); + + await markProxySessionEnded('uuid-1'); + + expect(await listProxySessions()).toEqual([]); + const all = await listProxySessions({ includeEnded: true }); + expect(all).toHaveLength(1); + expect(all[0]!.endedAt).toBeTruthy(); + + // Directory + co-located files remain — nothing is deleted on stop. + expect(existsSync(join(getProxySessionDir('uuid-1'), 'session.json'))).toBe(true); + expect(existsSync(join(getProxySessionDir('uuid-1'), 'grants.jsonl'))).toBe(true); + }); + + test('markProxySessionEnded is idempotent', async () => { + await createProxySessionRecord(record()); + await markProxySessionEnded('uuid-1'); + const [first] = await listProxySessions({ includeEnded: true }); + await markProxySessionEnded('uuid-1'); + const [second] = await listProxySessions({ includeEnded: true }); + expect(second!.endedAt).toBe(first!.endedAt); + }); + + test('a session whose owner process is dead is not active', async () => { + // pid 2^31-1 is effectively never a live process. + await createProxySessionRecord(record({ uuid: 'uuid-dead', ownerPid: 2_147_483_647 })); + expect(await listProxySessions()).toEqual([]); + expect(await listProxySessions({ includeEnded: true })).toHaveLength(1); + }); +}); diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index b957d7b9f..4065b9f9c 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -23,6 +23,8 @@ export type ProxySessionRecord = { cwd: string; startedAt: string; updatedAt: string; + /** Set once the session's process has stopped. A session dir is a durable record; it's marked ended, not deleted. */ + endedAt?: string; egressMode: ProxyEgressMode; schemaFingerprint?: string; placeholderOverrides?: Record; @@ -39,14 +41,28 @@ export type ProxySessionStats = { lastActivityAt?: string; }; -const SESSIONS_DIR = join(getUserVarlockDir(), 'proxy', 'sessions'); +// Resolved lazily (not a module-load const) so it honors the active +// XDG_CONFIG_HOME / legacy-dir resolution at call time — tests redirect it. +function getSessionsDir(): string { + return join(getUserVarlockDir(), 'proxy', 'sessions'); +} async function ensureSessionsDir() { - await mkdir(SESSIONS_DIR, { recursive: true, mode: 0o700 }); + await mkdir(getSessionsDir(), { recursive: true, mode: 0o700 }); +} + +/** + * Per-session directory: the durable record of one run, holding `session.json` + * plus its co-located `audit.jsonl` and `grants.jsonl`. The audit and grant + * modules build their paths off this so everything for a session lives together + * and survives the process (cleaned up deliberately, never on stop). + */ +export function getProxySessionDir(uuid: string): string { + return join(getSessionsDir(), uuid); } function getSessionFilePath(uuid: string): string { - return join(SESSIONS_DIR, `${uuid}.json`); + return join(getProxySessionDir(uuid), 'session.json'); } function nowIso(): string { @@ -79,6 +95,7 @@ function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | cwd: String(parsed.cwd), startedAt: String(parsed.startedAt), updatedAt: String(parsed.updatedAt), + ...(parsed.endedAt ? { endedAt: String(parsed.endedAt) } : {}), egressMode: parsed.egressMode as ProxyEgressMode, ...(parsed.schemaFingerprint ? { schemaFingerprint: String(parsed.schemaFingerprint) } : {}), ...(parsed.placeholderOverrides && typeof parsed.placeholderOverrides === 'object' @@ -127,27 +144,32 @@ export function isProcessRunning(pid: number): boolean { } } +/** + * List proxy session records. By default returns only **active** sessions — + * not ended and with a live owner process. Pass `{ includeEnded: true }` to + * include the durable records of stopped sessions (e.g. `proxy status --all`). + */ export async function listProxySessions(opts?: { - cleanupStale?: boolean; + includeEnded?: boolean; }): Promise> { await ensureSessionsDir(); - const files = await readdir(SESSIONS_DIR); + const sessionsDir = getSessionsDir(); + const dirEntries = await readdir(sessionsDir, { withFileTypes: true }); const sessions: Array = []; - for (const fileName of files) { - if (!fileName.endsWith('.json')) continue; - const filePath = join(SESSIONS_DIR, fileName); + for (const dirEntry of dirEntries) { + if (!dirEntry.isDirectory()) continue; + const filePath = join(sessionsDir, dirEntry.name, 'session.json'); const raw = await readFile(filePath, 'utf8').catch(() => undefined); if (!raw) continue; const parsed = parseSessionRecord(raw, filePath); if (!parsed) continue; - const running = isProcessRunning(parsed.ownerPid); - if (!running && opts?.cleanupStale) { - await rm(filePath, { force: true }); - continue; + if (!opts?.includeEnded) { + if (parsed.endedAt) continue; + if (!isProcessRunning(parsed.ownerPid)) continue; } sessions.push(parsed); @@ -156,8 +178,35 @@ export async function listProxySessions(opts?: { return sessions.sort((a, b) => a.startedAt.localeCompare(b.startedAt)); } +/** + * Mark a session ended (stamp `endedAt`) while keeping its directory and the + * co-located audit/grants as a durable record. Idempotent: a session already + * marked ended is left as-is. Reads the file directly by uuid so it works even + * after the owner process has died (when the active-only listing wouldn't find it). + */ +export async function markProxySessionEnded(uuid: string) { + const filePath = getSessionFilePath(uuid); + const raw = await readFile(filePath, 'utf8').catch(() => undefined); + if (!raw) return; + const existing = parseSessionRecord(raw, filePath); + if (!existing || existing.endedAt) return; + const ts = nowIso(); + const next: ProxySessionRecord = { ...existing, endedAt: ts, updatedAt: ts }; + await writeFile(filePath, JSON.stringify(next, null, 2), { mode: 0o600 }); +} + +/** + * Mark any session whose owner process has died (but wasn't marked ended — e.g. + * a hard kill that skipped the graceful cleanup) as ended, so the active list + * stays accurate. The record and its co-located audit/grants are kept. + */ export async function cleanupStaleProxySessions() { - await listProxySessions({ cleanupStale: true }); + const all = await listProxySessions({ includeEnded: true }); + for (const session of all) { + if (!session.endedAt && !isProcessRunning(session.ownerPid)) { + await markProxySessionEnded(session.uuid); + } + } } export async function reserveProxySessionIdentity(): Promise<{ id: string; uuid: string }> { @@ -170,7 +219,7 @@ export async function reserveProxySessionIdentity(): Promise<{ id: string; uuid: } export async function createProxySessionRecord(session: Omit) { - await ensureSessionsDir(); + await mkdir(getProxySessionDir(session.uuid), { recursive: true, mode: 0o700 }); const next: ProxySessionRecord = { ...session, updatedAt: nowIso(), @@ -202,7 +251,7 @@ export async function resolveActiveProxySession( ): Promise { // Fast exit for the common case (proxy never used): don't create or read the // sessions dir on every varlock invocation. - if (!existsSync(SESSIONS_DIR)) return undefined; + if (!existsSync(getSessionsDir())) return undefined; const sessions = await listProxySessions(); if (!sessions.length) return undefined; @@ -240,10 +289,6 @@ export async function updateProxySessionRecord( return next; } -export async function deleteProxySessionRecord(uuid: string) { - await rm(getSessionFilePath(uuid), { force: true }); -} - export async function resolveProxySessionForCommand(opts?: { explicitSession?: string; env?: NodeJS.ProcessEnv; From fbcf20adb4ca0111abc339505381ac25504abc85 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 16 Jun 2026 15:12:51 -0700 Subject: [PATCH 20/64] docs(varlock): document the credential proxy (guide + decorator & CLI reference) --- .../src/content/docs/guides/proxy.mdx | 244 ++++++++++++++++++ .../content/docs/reference/cli-commands.mdx | 44 ++++ .../docs/reference/item-decorators.mdx | 51 ++++ .../docs/reference/root-decorators.mdx | 38 +++ packages/varlock-website/src/sidebar.ts | 1 + 5 files changed, 378 insertions(+) create mode 100644 packages/varlock-website/src/content/docs/guides/proxy.mdx diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx new file mode 100644 index 000000000..8999acc58 --- /dev/null +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -0,0 +1,244 @@ +--- +title: Credential proxy +description: Run AI agents and untrusted tools through a local proxy so they only ever see placeholder secrets, while real values are injected at the network boundary. +--- + +The credential proxy lets you run an AI agent (or any untrusted tool) so that it **never sees your real secrets** — it only ever has placeholders in its environment. The real values are injected into outbound requests at the network boundary, against a cryptographically verified upstream, and every request is logged. + +This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it — coding agents, MCP servers, third-party CLIs, or scripts. + +```env-spec title=".env.schema" +# @enableProxy(egress="permissive") +# --- +# @proxy(domain="api.openai.com") +OPENAI_API_KEY=sk-... +``` + +```bash +varlock proxy run -- claude +``` + +The agent launched above sees `OPENAI_API_KEY=vlk_placeholder_…` (or a format-shaped placeholder). When it makes a request to `api.openai.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. + +:::note[Approvals are not part of this release] +The proxy supports an experimental, interactive **approval** step (`approval=true`), but it is TTY-only and still being built out. The core protection described in this guide — placeholder isolation, wire-level injection, egress control, and auditing — does not depend on it. See [Approvals (experimental)](#approvals-experimental) at the end. +::: + +## How it works + +1. You mark which secrets are proxied with [`@proxy(domain=...)`](/reference/item-decorators/#proxy). +2. The child process is launched with **placeholders** in place of those secrets, plus the standard proxy/CA environment variables pointing at a local proxy. +3. The proxy intercepts HTTPS traffic. For a request that matches a rule, it verifies the upstream's real TLS identity, then substitutes the placeholder for the real secret **only on that connection**. +4. Responses are scrubbed so real values can't leak back into the child's output, and every request is recorded to an [audit log](#auditing). + +Secrets and the proxy's signing key live only in memory on your machine — they are never written to disk and never handed to the child. + +## Quick start + +#### 1. Enable the proxy + +Add the [`@enableProxy`](/reference/root-decorators/#enableproxy) root decorator to your schema header: + +```env-spec title=".env.schema" +# @enableProxy(egress="permissive") +# --- +``` + +#### 2. Route a secret through the proxy + +Add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** you want to protect. This both marks the item [`@sensitive`](/reference/item-decorators/#sensitive) and tells the proxy to inject its real value into requests to that host: + +```env-spec title=".env.schema" +# @enableProxy(egress="permissive") +# --- +# @proxy(domain="api.openai.com") +OPENAI_API_KEY=sk-... + +# @proxy(domain="api.stripe.com") +STRIPE_SECRET_KEY=sk_live_... +``` + +#### 3. Run your agent through it + +```bash +varlock proxy run -- claude +# or any command: +varlock proxy run -- node agent.js +varlock proxy run -- python tool.py +``` + +That's it — the child inherits everything it needs (proxy address + CA trust) automatically. + +:::caution[Put `@proxy` on the item, not in the header] +`@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block`/`approval` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected — it won't be. See [Routing rules](#routing-rules). +::: + +## Routing rules + +A `@proxy(...)` rule supports more than just a domain: + +| Option | Meaning | +|---|---| +| `domain` | **(required)** Target host to match. Supports globs, e.g. `*.example.com`. | +| `path` | Restrict to matching URL paths (glob), e.g. `path="/v1/**"`. | +| `method` | Restrict to an HTTP method, e.g. `method="POST"`. | +| `block` | `block=true` denies matching requests outright (fail closed). | + +```env-spec title=".env.schema" +# @enableProxy(egress="strict") +# Block a dangerous endpoint entirely (detached rule — no injection): +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method="POST", block=true) +# --- +# @proxy(domain="api.stripe.com") +STRIPE_SECRET_KEY=sk_live_... +``` + +### Attached vs detached rules + +- **Attached rule** — `@proxy` on an item. Injects that item's secret into matching requests. (You can attach extra items by passing refs: `@proxy(domain="api.x.com", $OTHER_KEY)`.) +- **Detached rule** — `@proxy` in the header. A policy-only rule (match, `block`, or `approval`) for a domain, with no secret injected. + +### Egress modes + +`@enableProxy(egress=...)` controls what happens to requests that **don't** match any rule: + +- `permissive` _(default)_ — unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. +- `strict` — only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. + +## Controlling what the agent sees + +By default, varlock applies **least privilege** to the proxied child: + +- A `@proxy(domain=...)` item → the agent sees a **placeholder**; the real value is injected at the wire. +- A [`@sensitive`](/reference/item-decorators/#sensitive) item with **no** proxy policy → **omitted entirely** from the child (you'll see a warning). The agent gets neither the real value nor a placeholder. +- Non-sensitive items → passed through normally. + +To override the default for an item, use the value form of `@proxy`: + +```env-spec title=".env.schema" +# @sensitive +# @proxy=passthrough # inject the REAL value into the child (escape hatch) +LEGACY_TOKEN=... + +# @proxy=omit # explicitly withhold from the child (no warning) +UNUSED_SECRET=... +``` + +`@proxy=passthrough` and `@proxy=omit` are the **value form** of the decorator and cannot be combined with the function form (`@proxy(...)`) on the same item. + +### Placeholders + +The placeholder the agent sees is chosen in priority order: + +1. An explicit [`@placeholder`](/reference/item-decorators/#placeholder) value — always wins. +2. A format derived from the item's [`@type`](/reference/item-decorators/#type) — e.g. `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. +3. A generic fallback (`vlk_placeholder__…`) — varlock **warns** about these, because a generic placeholder can fail an SDK's client-side key-format check (e.g. an `sk-…` prefix assertion) before the request is ever made. + +If you see the generic-placeholder warning, add an `@placeholder` or a typed format so the placeholder looks valid to the client: + +```env-spec title=".env.schema" +# @proxy(domain="api.openai.com") +# @placeholder=sk-proj-000000000000000000000000 +OPENAI_API_KEY=sk-proj-... +``` + +## Running modes + +There are three ways to drive the proxy, trading convenience for interactivity. + +### One-shot: `proxy run` + +```bash +varlock proxy run -- +``` + +Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a `proxy start` daemon is already running for this directory, `proxy run` **attaches** to it (so its terminal can handle approval prompts); otherwise it runs a self-contained proxy. Pass `--new` to force a fresh, separate proxy. + +### Daemon + attach: `proxy start` + +```bash +# Terminal 1 — owns the proxy and any approval prompts +varlock proxy start + +# Terminal 2 — attaches automatically +varlock proxy run -- claude +``` + +`proxy start` is a long-lived session that owns its terminal. This is the mode to use if you want interactive [approvals](#approvals-experimental), since the prompt appears in the daemon's terminal rather than competing with the agent's stdio. + +### Manual env: `proxy start` + `proxy env` + +```bash +varlock proxy start # in one terminal +eval "$(varlock proxy env)" # in another shell +node agent.js # now routed through the proxy +``` + +`proxy env` prints the proxy + CA environment for an existing session so you can source it into any shell or tool. + +## How the child is wired + +`proxy run` (and `proxy env`) inject a standard set of environment variables into the child so common HTTP clients trust the proxy with no manual setup: + +- **Proxy address:** `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` (and lowercase), with `NO_PROXY` excluding localhost. +- **CA trust:** `NODE_EXTRA_CA_CERTS` (Node.js), `REQUESTS_CA_BUNDLE` (Python `requests`), `CURL_CA_BUNDLE` (curl), `GIT_SSL_CAINFO` (git), and `SSL_CERT_FILE` (OpenSSL-based tools). + +The proxy uses an **ephemeral, in-memory certificate authority** generated per run; only its public certificate is written to a temp file for the child to trust. The CA private key and all per-host certificates never leave memory. + +:::note[Clients that don't read these env vars] +Tools that ignore the standard proxy/CA environment variables — many GUI apps, browsers, and some language runtimes with their own trust stores — won't be transparently proxied. The target here is CLI tools and agents, which generally do respect them. +::: + +## 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). + +```bash +varlock proxy audit # current/most-recent session +varlock proxy audit --session # a specific session +varlock proxy audit --format json # machine-readable +``` + +Sessions are kept as durable records after they end. List them with: + +```bash +varlock proxy status # active sessions +varlock proxy status --all # include ended sessions +varlock proxy status --watch # live updates +``` + +## Editing the schema while a session is running + +For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started (this prevents an agent from editing the schema to downgrade `@sensitive` and recover real values). After an intentional edit, re-approve it from a trusted shell: + +```bash +varlock proxy refresh --session +``` + +## Approvals (experimental) + +:::caution[Experimental — TTY only] +Approvals work today only via an interactive `varlock proxy start` terminal. There is no phone/native approver yet, and running an approval-gated schema through a non-interactive `proxy run` (with no daemon to attach to) will **deny** the held requests. Treat this as a preview. +::: + +A rule can require explicit, out-of-band approval before each matching request is forwarded: + +```env-spec title=".env.schema" +# @enableProxy(egress="strict") +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", approval=true) +# --- +# @proxy(domain="api.stripe.com") +STRIPE_SECRET_KEY=sk_live_... +``` + +When matched under `proxy start`, the request is held and you're prompted to approve it (once, for the session, or for a time window). Two knobs tune this: + +- `approvalEach` — what one approval covers: `host`, `endpoint` (method + path, the default), or `request` (also binds the body). +- `approvalMaxDuration` — a ceiling on how long a "yes" is remembered: a duration like `"15m"`, `0` for always-ask, or unset for the whole session. The ceiling is enforced by the proxy, not just the prompt. + +## Reference + +- [`@enableProxy`](/reference/root-decorators/#enableproxy) and [`@proxy`](/reference/root-decorators/#proxy) root decorators +- [`@proxy`](/reference/item-decorators/#proxy) item decorator +- [`varlock proxy`](/reference/cli-commands/#proxy) CLI commands +- [AI Tools guide](/guides/ai-tools/) diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index fa4c56560..d5379ff77 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -636,6 +636,50 @@ You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` env ::: +
+### `varlock proxy` ||proxy|| + +Manages [credential proxy](/guides/proxy/) sessions — running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in your schema. + +```bash +varlock proxy [options] +``` + +**Subcommands:** +- `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. +- `start`: Start a long-lived proxy session that owns the terminal (interactive approval prompts appear here). Stop with `Ctrl+C`. +- `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). +- `status`: List active proxy sessions. +- `audit`: Print a session's request audit log (no secret values). +- `refresh`: Re-validate and reload the schema into a running session after an intentional edit. +- `stop`: Stop a session. + +**Options:** +- `--session `: Target a specific session by id. +- `--new`: For `run`, force a fresh proxy instead of attaching to a running one. +- `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). +- `--inject `: For `run`, control what is injected into the child env (default `all`). +- `--no-redact-stdout`: For `run`, disable stdout/stderr redaction (full TTY pass-through). +- `--watch`: For `status`, continuously refresh. +- `--format `: Output format for `audit` (text/json) and `env` (shell/json). + +**Examples:** +```bash +# Run an agent through the proxy +varlock proxy run -- claude + +# Daemon in one terminal, attach from another (enables interactive approvals) +varlock proxy start +varlock proxy run -- node agent.js + +# Inspect activity +varlock proxy status +varlock proxy audit --format json +``` + +See the [credential proxy guide](/guides/proxy/) for the full workflow. +
+
### `varlock generate-key` ||generate-key|| diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 3f772dde3..268ce28bc 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -234,4 +234,55 @@ Suppresses "unused in schema" warnings from [`varlock audit`](/reference/cli-com CI_DEPLOY_TOKEN= ```
+ +
+### `@placeholder` +**Value type:** `string` + +Sets an explicit placeholder string for the item, used in place of the real value when the item is routed through the [credential proxy](/guides/proxy/). A placeholder is what an untrusted child process (e.g. an AI agent) sees instead of the secret. + +Without an explicit `@placeholder`, varlock derives one from the item's [`@type`](#type) format, falling back to a generic value. Set this when a generic placeholder would fail a client's key-format validation (e.g. an `sk-…` prefix check). + +```env-spec +# @proxy(domain="api.openai.com") +# @placeholder=sk-proj-000000000000000000000000 +OPENAI_API_KEY=sk-proj-... +``` +
+ +
+### `@proxy` +**Value type:** function `@proxy(domain=..., ...)` _or_ value `@proxy=passthrough|omit` + +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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). + +**Function form** — `@proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], $REFS)`: + +| Option | Meaning | +|---|---| +| `domain` | **(required)** Host to match; supports globs (`*.example.com`). | +| `path` | Restrict to matching URL paths (glob), e.g. `"/v1/**"`. | +| `method` | Restrict to an HTTP method, e.g. `"POST"`. | +| `block` | `block=true` denies matching requests outright. | +| `approval` _(experimental)_ | `approval=true` holds matching requests for interactive approval. | +| `approvalEach` _(experimental)_ | What one approval covers: `host`|`endpoint`|`request`. | +| `approvalMaxDuration` _(experimental)_ | Ceiling on how long a "yes" is remembered (`"15m"`, `0` = always ask). | + +Positional `$REF`s attach additional items to the rule. The same decorator in the **header** creates a _detached_ policy rule (no injection). + +**Value form** — `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. + +```env-spec +# @enableProxy(egress="strict") +# --- +# @proxy(domain="api.stripe.com", path="/v1/**") +STRIPE_SECRET_KEY=sk_live_... + +# @sensitive +# @proxy=passthrough +LEGACY_TOKEN=... +``` + +See the [credential proxy guide](/guides/proxy/) for the full workflow. +
diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 1466b1489..d1602327e 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -485,4 +485,42 @@ API_KEY= ``` +
+### `@enableProxy()` +**Arg types:** `(egress?: "permissive" | "strict")` + +Enables the [credential proxy](/guides/proxy/), which lets you run an untrusted child process (e.g. an AI agent) that only ever sees placeholder secrets while real values are injected at the network boundary. + +The `egress` option controls requests that don't match any [`@proxy`](#proxy) rule: +- `permissive` _(default)_ — unmatched hosts pass through untouched. +- `strict` — only requests matching a `@proxy` rule are allowed; everything else is blocked. + +```env-spec +# @enableProxy(egress="strict") +# --- +# @proxy(domain="api.openai.com") +OPENAI_API_KEY=sk-... +``` + +See the [credential proxy guide](/guides/proxy/) for the full workflow. +
+ +
+### `@proxy()` +**Arg types:** `(domain: string, path?, method?, block?, approval?, ...)` + +Defines a **detached** [credential proxy](/guides/proxy/) rule — a domain-level policy (match, `block`, or `approval`) that injects **no** secret. Requires [`@enableProxy`](#enableproxy). + +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). + +```env-spec +# @enableProxy(egress="permissive") +# Block a dangerous endpoint regardless of which key would be used: +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method="POST", block=true) +# --- +# @proxy(domain="api.stripe.com") +STRIPE_SECRET_KEY=sk_live_... +``` +
+ diff --git a/packages/varlock-website/src/sidebar.ts b/packages/varlock-website/src/sidebar.ts index a64bf4015..d27743f92 100644 --- a/packages/varlock-website/src/sidebar.ts +++ b/packages/varlock-website/src/sidebar.ts @@ -33,6 +33,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [ { label: 'Telemetry', slug: 'guides/telemetry' }, { label: 'MCP', slug: 'guides/mcp' }, { label: 'AI Tools', slug: 'guides/ai-tools' }, + { label: 'Credential proxy', slug: 'guides/proxy', badge: 'new' }, ], }, { From 43615bc89496deb375c8a0fd800ce796284cdea3 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 16 Jun 2026 15:39:02 -0700 Subject: [PATCH 21/64] feat(varlock): @proxy uses array literals for domain/method lists and keys=[...] --- .bumpy/proxy-array-literals.md | 7 ++ .../src/content/docs/guides/proxy.mdx | 16 ++- .../docs/reference/item-decorators.mdx | 9 +- .../docs/reference/root-decorators.mdx | 14 ++- .../cli/helpers/proxy-schema-fingerprint.ts | 11 ++ .../varlock/src/env-graph/lib/decorators.ts | 109 +++++++++++------- .../varlock/src/env-graph/lib/env-graph.ts | 45 +++----- .../src/env-graph/test/proxy-mode.test.ts | 59 ++++++++++ packages/varlock/src/proxy/policy.test.ts | 10 +- packages/varlock/src/proxy/policy.ts | 6 +- packages/varlock/src/proxy/proxy-tls.test.ts | 2 +- packages/varlock/src/proxy/types.ts | 3 +- 12 files changed, 201 insertions(+), 90 deletions(-) create mode 100644 .bumpy/proxy-array-literals.md diff --git a/.bumpy/proxy-array-literals.md b/.bumpy/proxy-array-literals.md new file mode 100644 index 000000000..48400bc65 --- /dev/null +++ b/.bumpy/proxy-array-literals.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`@proxy` uses array literals for lists. + +`domain` and `method` now accept an array literal for matching multiple values (`domain=[api.a.com, api.b.com]`, `method=[GET, POST]`), and a detached rule attaches extra items with `keys=[ITEM_A, ITEM_B]` instead of positional `$REF`s. A single value still works (`domain="api.x.com"`). diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 8999acc58..d6207ca42 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -79,24 +79,28 @@ A `@proxy(...)` rule supports more than just a domain: | Option | Meaning | |---|---| -| `domain` | **(required)** Target host to match. Supports globs, e.g. `*.example.com`. | +| `domain` | **(required)** Host to match — a single host or an array list. Supports globs, e.g. `*.example.com`. | | `path` | Restrict to matching URL paths (glob), e.g. `path="/v1/**"`. | -| `method` | Restrict to an HTTP method, e.g. `method="POST"`. | +| `method` | Restrict to one or more HTTP methods, e.g. `method=[GET, POST]`. | | `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]`. | + +`domain` and `method` take either a single value or an **array literal** for lists: ```env-spec title=".env.schema" # @enableProxy(egress="strict") # Block a dangerous endpoint entirely (detached rule — no injection): -# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method="POST", block=true) +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # --- -# @proxy(domain="api.stripe.com") +# Match either host, any method: +# @proxy(domain=[api.stripe.com, api.stripe-test.com]) STRIPE_SECRET_KEY=sk_live_... ``` ### Attached vs detached rules -- **Attached rule** — `@proxy` on an item. Injects that item's secret into matching requests. (You can attach extra items by passing refs: `@proxy(domain="api.x.com", $OTHER_KEY)`.) -- **Detached rule** — `@proxy` in the header. A policy-only rule (match, `block`, or `approval`) for a domain, with no secret injected. +- **Attached rule** — `@proxy` on an item. Injects that item's secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) +- **Detached rule** — `@proxy` in the header. A policy-only rule (match, `block`, or `approval`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. ### Egress modes diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 268ce28bc..0d9d79135 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,19 +256,20 @@ OPENAI_API_KEY=sk-proj-... 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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). -**Function form** — `@proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], $REFS)`: +**Function form** — `@proxy(domain=..., [path], [method], [block], [keys], [approval], [approvalEach], [approvalMaxDuration])`: | Option | Meaning | |---|---| -| `domain` | **(required)** Host to match; supports globs (`*.example.com`). | +| `domain` | **(required)** Host to match — a single host or an array (`[a.com, b.com]`); supports globs (`*.example.com`). | | `path` | Restrict to matching URL paths (glob), e.g. `"/v1/**"`. | -| `method` | Restrict to an HTTP method, e.g. `"POST"`. | +| `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | +| `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. | | `approval` _(experimental)_ | `approval=true` holds matching requests for interactive approval. | | `approvalEach` _(experimental)_ | What one approval covers: `host`|`endpoint`|`request`. | | `approvalMaxDuration` _(experimental)_ | Ceiling on how long a "yes" is remembered (`"15m"`, `0` = always ask). | -Positional `$REF`s attach additional items to the rule. The same decorator in the **header** creates a _detached_ policy rule (no injection). +The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`). **Value form** — `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index d1602327e..efb896a44 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -507,19 +507,23 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow.
### `@proxy()` -**Arg types:** `(domain: string, path?, method?, block?, approval?, ...)` +**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], approval?, ...)` -Defines a **detached** [credential proxy](/guides/proxy/) rule — a domain-level policy (match, `block`, or `approval`) that injects **no** secret. Requires [`@enableProxy`](#enableproxy). +Defines a **detached** [credential proxy](/guides/proxy/) rule — a domain-level policy (match, `block`, or `approval`). It injects no secret on its own, but can inject named items via `keys=[...]`. Requires [`@enableProxy`](#enableproxy). 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). ```env-spec # @enableProxy(egress="permissive") # Block a dangerous endpoint regardless of which key would be used: -# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method="POST", block=true) +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) +# Inject several keys for a host: +# @proxy(domain="api.stripe.com", keys=[STRIPE_KEY, WEBHOOK_SECRET]) # --- -# @proxy(domain="api.stripe.com") -STRIPE_SECRET_KEY=sk_live_... +# @sensitive +STRIPE_KEY=sk_live_... +# @sensitive +WEBHOOK_SECRET=whsec_... ```
diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index bc0f342b3..1ff6a8967 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -1,9 +1,11 @@ import { createHash } from 'node:crypto'; import { + ParsedEnvSpecArrayLiteral, ParsedEnvSpecFunctionArgs, ParsedEnvSpecFunctionCall, ParsedEnvSpecKeyValuePair, + ParsedEnvSpecObjectLiteral, ParsedEnvSpecStaticValue, } from '@env-spec/parser'; @@ -38,6 +40,15 @@ function canonicalParsed(node: unknown): string { named.sort(); return `(${[...positional, ...named].join(',')})`; } + if (node instanceof ParsedEnvSpecArrayLiteral) { + // Array order is significant (e.g. domain/method lists) — keep it. + return `[${node.values.map((v) => canonicalParsed(v)).join(',')}]`; + } + if (node instanceof ParsedEnvSpecObjectLiteral) { + const entries = node.values.map((kv) => `${kv.key}=${canonicalParsed(kv.value)}`); + entries.sort(); + return `{${entries.join(',')}}`; + } return '?'; } diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 87493b4bf..aea29784a 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -7,7 +7,9 @@ import { } from '@env-spec/parser'; import { EnvGraphDataSource } from './data-source'; import type { ConfigItem } from './config-item'; -import { StaticValueResolver, type Resolver, convertParsedValueToResolvers } from './resolver'; +import { + StaticValueResolver, ArrayLiteralResolver, type Resolver, convertParsedValueToResolvers, +} from './resolver'; import { ResolutionError, SchemaError, type VarlockError } from './errors'; import type { EnvGraph } from './env-graph'; import { parseKeyFilterArgs, applyKeyFilter, type KeyFilter } from './key-filter'; @@ -304,6 +306,70 @@ export type RootDecoratorDef = { useFnArgsResolver?: boolean, }; +/** + * Validate that a `@proxy` list option (`domain`/`method`/`keys`) is either a + * single string value or an array literal of non-empty static strings. When + * `requireArray` is set the single form is rejected (used for `keys`, which is + * always a list). Single non-array values are accepted as-is and validated at + * resolve time, so dynamic expressions (e.g. `domain=concat(...)`) still work. + */ +function assertProxyStringListArg( + resolver: Resolver | undefined, + option: string, + requireArray: boolean, +): void { + if (!resolver) return; + if (resolver instanceof ArrayLiteralResolver) { + const els = resolver.arrArgs ?? []; + if (!els.length) throw new SchemaError(`@proxy: ${option} array cannot be empty`); + for (const el of els) { + if (!el.isStatic || typeof el.staticValue !== 'string' || !el.staticValue.trim()) { + throw new SchemaError(`@proxy: ${option} entries must be non-empty strings`); + } + } + return; + } + if (requireArray) { + throw new SchemaError(`@proxy: ${option} must be an array literal, e.g. ${option}=[ITEM_A, ITEM_B]`); + } +} + +/** + * Shared validation for the function form of `@proxy(...)` — used by both the + * root (detached) and item (attached) registrations so their rules stay + * consistent. Requires `domain`; accepts `domain`/`method` as a string or array + * literal and `keys` as an array literal; rejects positional args; validates the + * approval options. + */ +function validateProxyFunctionArgs(argsVal: Resolver): void { + if (!argsVal.objArgs?.domain) { + throw new SchemaError('@proxy: missing required "domain" option'); + } + assertProxyStringListArg(argsVal.objArgs.domain, 'domain', false); + assertProxyStringListArg(argsVal.objArgs?.method, 'method', false); + assertProxyStringListArg(argsVal.objArgs?.keys, 'keys', true); + + if (argsVal.arrArgs?.length) { + throw new SchemaError('@proxy: positional args are not supported - use keys=[ITEM_A, ITEM_B] to attach items'); + } + + const eachResolver = argsVal.objArgs?.approvalEach; + if (eachResolver?.isStatic) { + const eachVal = eachResolver.staticValue; + if (typeof eachVal !== 'string' || !PROXY_APPROVAL_EACH_VALUES.includes(eachVal as any)) { + throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); + } + } + const maxDurResolver = argsVal.objArgs?.approvalMaxDuration; + if (maxDurResolver?.isStatic) { + try { + parseDuration(maxDurResolver.staticValue as string | number); + } catch { + throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); + } + } +} + // root decorators export const builtInRootDecorators: Array> = [ { @@ -399,18 +465,7 @@ export const builtInRootDecorators: Array> = [ name: 'proxy', isFunction: true, useFnArgsResolver: true, - process: (argsVal) => { - const domainResolver = argsVal.objArgs?.domain; - if (!domainResolver) { - throw new SchemaError('@proxy: missing required "domain" option'); - } - - for (const arg of argsVal.arrArgs || []) { - if (arg.fnName !== 'ref') { - throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); - } - } - }, + process: (argsVal) => validateProxyFunctionArgs(argsVal), }, { name: 'auditIgnorePaths', @@ -639,32 +694,8 @@ export const builtInItemDecorators: Array> = [ } return; } - // Function form: @proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], $REFS) - const domainResolver = decVal.objArgs?.domain; - if (!domainResolver) { - throw new SchemaError('@proxy: missing required "domain" option'); - } - for (const arg of decVal.arrArgs || []) { - if (arg.fnName !== 'ref') { - throw new SchemaError('@proxy: positional args must be item refs like $API_KEY'); - } - } - const eachResolver = decVal.objArgs?.approvalEach; - if (eachResolver?.isStatic) { - const eachVal = eachResolver.staticValue; - if (typeof eachVal !== 'string' || !PROXY_APPROVAL_EACH_VALUES.includes(eachVal as any)) { - throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); - } - } - const maxDurResolver = decVal.objArgs?.approvalMaxDuration; - if (maxDurResolver?.isStatic) { - const maxVal = maxDurResolver.staticValue; - try { - parseDuration(maxVal as string | number); - } catch { - throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); - } - } + // Function form: @proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], [keys=[...]]) + validateProxyFunctionArgs(decVal); }, }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 6e62a7f2c..00b5ec8d1 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -15,7 +15,6 @@ import { generateTypes } from './type-generation'; import { builtInItemDecorators, builtInRootDecorators, - ItemDecoratorInstance, RootDecoratorInstance, type ItemDecoratorDef, type RootDecoratorDef, @@ -880,27 +879,19 @@ export class EnvGraph { /** plugins installed globally in the graph */ plugins: Array = []; - private static normalizeDomainList(domainValue: unknown): Array { - if (!_.isString(domainValue)) return []; - return domainValue - .split(',') - .map((d) => d.trim()) + /** + * Normalize a `@proxy` list option (`domain`, `method`, `keys`) into a string + * array. Accepts a single string (`domain="api.x.com"`) or an array literal + * (`domain=[a.com, b.com]`); trims and drops empties. + */ + private static normalizeStringList(value: unknown): Array { + const raw = Array.isArray(value) ? value : [value]; + return raw + .filter((v): v is string => _.isString(v)) + .map((v) => v.trim()) .filter(Boolean); } - private static collectRefItemKeysFromResolverArgs(dec: RootDecoratorInstance | ItemDecoratorInstance): Array { - const arrArgs = dec?.decValueResolver?.arrArgs; - if (!arrArgs) return []; - - const itemKeys: Array = []; - for (const arg of arrArgs) { - if (arg.fnName === 'ref' && _.isString(arg.arrArgs?.[0]?.staticValue)) { - itemKeys.push(arg.arrArgs[0].staticValue); - } - } - return itemKeys; - } - /** * Approval fields for a rule, from a resolved `@proxy(...)` arg object. * Approval is required if `approval=true` or any approval config prop is set. @@ -924,15 +915,16 @@ export class EnvGraph { // detached rules from root-level @proxy(...) for (const rootProxyDec of this.getRootDecFns('proxy')) { const resolved = await rootProxyDec.resolve(); - const domain = EnvGraph.normalizeDomainList(resolved?.obj?.domain); + const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; + const method = EnvGraph.normalizeStringList(resolved?.obj?.method); rules.push({ source: 'detached', domain, - itemKeys: EnvGraph.collectRefItemKeysFromResolverArgs(rootProxyDec), + itemKeys: EnvGraph.normalizeStringList(resolved?.obj?.keys), ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), - ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), + ...(method.length ? { method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), ...EnvGraph.buildProxyApprovalFields(resolved?.obj), }); @@ -943,17 +935,18 @@ export class EnvGraph { const item = this.configSchema[itemKey]; for (const itemProxyDec of item.getDecFns('proxy')) { const resolved = await itemProxyDec.resolve(); - const domain = EnvGraph.normalizeDomainList(resolved?.obj?.domain); + const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; - const detachedItemKeys = EnvGraph.collectRefItemKeysFromResolverArgs(itemProxyDec); - const itemKeys = _.uniq([itemKey, ...detachedItemKeys]); + const method = EnvGraph.normalizeStringList(resolved?.obj?.method); + const extraKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); + const itemKeys = _.uniq([itemKey, ...extraKeys]); rules.push({ source: 'attached', domain, itemKeys, ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), - ...(_.isString(resolved?.obj?.method) ? { method: resolved.obj.method } : {}), + ...(method.length ? { method } : {}), ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), ...EnvGraph.buildProxyApprovalFields(resolved?.obj), }); diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 98509f87e..06f8c8716 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -54,6 +54,65 @@ describe('proxy decorators', () => { ]); }); + test('domain and method accept array literals (lists)', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain=[api.a.com, api.b.com], method=[GET, POST]) + API_KEY=secret + `); + + expect(await graph.getProxyRules()).toMatchObject([ + { + source: 'attached', + domain: ['api.a.com', 'api.b.com'], + method: ['GET', 'POST'], + itemKeys: ['API_KEY'], + }, + ]); + }); + + test('detached rule attaches extra items via keys=[...] array literal', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # @proxy(domain="api.example.com", keys=[STRIPE_KEY, WEBHOOK_SECRET]) + # --- + # @sensitive + STRIPE_KEY=sk_live_real + + # @sensitive + WEBHOOK_SECRET=whsec_real + `); + + const rules = await graph.getProxyRules(); + expect(rules).toMatchObject([{ source: 'detached', domain: ['api.example.com'], itemKeys: ['STRIPE_KEY', 'WEBHOOK_SECRET'] }]); + // and those keys become managed (placeholders injected) + const managed = await graph.getProxyManagedItems(); + expect(managed.map((i) => i.key).sort()).toEqual(['STRIPE_KEY', 'WEBHOOK_SECRET']); + }); + + test('positional args are rejected with a pointer to keys=[...]', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(OTHER_KEY, domain="api.a.com") + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /positional args are not supported.*keys=\[/.test(e.message))).toBe(true); + }); + + test('keys must be an array literal, not a bare value', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", keys=OTHER) + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /keys must be an array literal/.test(e.message))).toBe(true); + }); + test('a header-level (detached) @proxy is not rejected as a misplaced item decorator', async () => { const graph = await loadGraph(outdent` # @enableProxy(egress="strict") diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts index 282e88822..0f0d047bd 100644 --- a/packages/varlock/src/proxy/policy.test.ts +++ b/packages/varlock/src/proxy/policy.test.ts @@ -12,7 +12,7 @@ const facts = (host: string, method: string, path: string): RequestFacts => ({ h describe('proxy policy matching', () => { test('matches on domain + path glob + method', () => { - const r = rule({ domain: ['api.x.com'], path: '/v1/customers/*', method: 'GET' }); + const r = rule({ domain: ['api.x.com'], path: '/v1/customers/*', method: ['GET'] }); expect(ruleMatchesFacts(r, facts('api.x.com', 'GET', '/v1/customers/42'))).toBe(true); // method mismatch expect(ruleMatchesFacts(r, facts('api.x.com', 'POST', '/v1/customers/42'))).toBe(false); @@ -22,9 +22,9 @@ describe('proxy policy matching', () => { expect(ruleMatchesFacts(r, facts('evil.com', 'GET', '/v1/customers/42'))).toBe(false); }); - test('`**` matches across segments; comma methods; domain-only matches any path/method', () => { + test('`**` matches across segments; method lists; domain-only matches any path/method', () => { expect(ruleMatchesFacts(rule({ domain: ['api.x.com'], path: '/v1/**' }), facts('api.x.com', 'GET', '/v1/a/b/c'))).toBe(true); - expect(ruleMatchesFacts(rule({ domain: ['api.x.com'], method: 'GET,POST' }), facts('api.x.com', 'POST', '/any'))).toBe(true); + expect(ruleMatchesFacts(rule({ domain: ['api.x.com'], method: ['GET', 'POST'] }), facts('api.x.com', 'POST', '/any'))).toBe(true); expect(ruleMatchesFacts(rule({ domain: ['api.x.com'] }), facts('api.x.com', 'DELETE', '/whatever'))).toBe(true); }); }); @@ -33,7 +33,7 @@ describe('evaluateProxyPolicy', () => { const rules = [ rule({ domain: ['api.stripe.com'], itemKeys: ['STRIPE_KEY'] }), rule({ - domain: ['api.stripe.com'], path: '/v1/charges', method: 'POST', block: true, + domain: ['api.stripe.com'], path: '/v1/charges', method: ['POST'], block: true, }), ]; @@ -92,7 +92,7 @@ describe('getRequestScopedManagedItems', () => { test('injects only when domain + path + method all match', () => { const rules = [ rule({ - domain: ['api.x.com'], path: '/v1/read/*', method: 'GET', itemKeys: ['K'], + domain: ['api.x.com'], path: '/v1/read/*', method: ['GET'], itemKeys: ['K'], }), ]; expect(getRequestScopedManagedItems(facts('api.x.com', 'GET', '/v1/read/1'), rules, items).map((i) => i.key)).toEqual(['K']); diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts index 620e159ed..c0de0f551 100644 --- a/packages/varlock/src/proxy/policy.ts +++ b/packages/varlock/src/proxy/policy.ts @@ -47,8 +47,8 @@ function pathMatches(pattern: string, path: string): boolean { return new RegExp(`^${re}$`).test(path); } -function methodMatches(ruleMethod: string, method: string): boolean { - const allowed = ruleMethod.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean); +function methodMatches(ruleMethods: Array, method: string): boolean { + const allowed = ruleMethods.map((m) => m.trim().toUpperCase()).filter(Boolean); return allowed.length === 0 || allowed.includes(method.toUpperCase()); } @@ -72,7 +72,7 @@ function ruleSpecificity(rule: ProxyRule): number { */ export function describeRule(rule: ProxyRule): string { const parts = [rule.domain.join('|')]; - if (rule.method !== undefined) parts.push(rule.method.toUpperCase()); + if (rule.method !== undefined) parts.push(rule.method.map((m) => m.toUpperCase()).join('|')); if (rule.path !== undefined) parts.push(rule.path); if (rule.block) parts.push('block'); if (rule.approval) parts.push('approval'); diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index cd5b0a26b..86d8ceba8 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -345,7 +345,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { rules: [ { source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }, { - source: 'detached', domain: [UPSTREAM_HOST], path: '/v1/charges', method: 'POST', itemKeys: [], block: true, + source: 'detached', domain: [UPSTREAM_HOST], path: '/v1/charges', method: ['POST'], itemKeys: [], block: true, }, ], egressMode: 'permissive', diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index 4ed35dada..a4a14ab49 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -15,7 +15,8 @@ export type ProxyRule = { domain: Array; itemKeys: Array; path?: string; - method?: string; + /** Allowed HTTP methods (uppercased). Omitted = any method. */ + method?: Array; block?: boolean; /** Require out-of-band approval before this request is forwarded (Invariant #8). */ approval?: boolean; From c2e0f3b9b3f972f25164bdb54ad76f218b776f21 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 16 Jun 2026 16:45:11 -0700 Subject: [PATCH 22/64] fix(varlock): proxy fingerprint treats domain=a and domain=[a] as identical --- .../cli/helpers/proxy-schema-fingerprint.ts | 5 ++++- .../test/proxy-schema-fingerprint.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index 1ff6a8967..d7784c6b9 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -41,7 +41,10 @@ function canonicalParsed(node: unknown): string { return `(${[...positional, ...named].join(',')})`; } if (node instanceof ParsedEnvSpecArrayLiteral) { - // Array order is significant (e.g. domain/method lists) — keep it. + // A single-element array is treated as identical to the bare value, so e.g. + // `domain=a` and `domain=[a]` (semantically equal) fingerprint the same. + if (node.values.length === 1) return canonicalParsed(node.values[0]); + // Otherwise array order is significant (e.g. domain/method lists) — keep it. return `[${node.values.map((v) => canonicalParsed(v)).join(',')}]`; } if (node instanceof ParsedEnvSpecObjectLiteral) { diff --git a/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts index aaeef8f9f..f8f248b07 100644 --- a/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts +++ b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts @@ -49,6 +49,25 @@ describe('buildProxySchemaFingerprint', () => { expect(await fp(BASE.replace('api.x.com', 'api.y.com'))).not.toBe(await fp(BASE)); }); + test('a single value and a single-element array are identical (domain=a ≡ domain=[a])', async () => { + const single = outdent` + # @enableProxy(egress="strict") + # --- + # @sensitive @proxy(domain="api.x.com") + SECRET=abc + `; + const arrayOfOne = outdent` + # @enableProxy(egress="strict") + # --- + # @sensitive @proxy(domain=["api.x.com"]) + SECRET=abc + `; + expect(await fp(arrayOfOne)).toBe(await fp(single)); + // but a real multi-element list is still distinct + const arrayOfTwo = single.replace('domain="api.x.com"', 'domain=[api.x.com, api.y.com]'); + expect(await fp(arrayOfTwo)).not.toBe(await fp(single)); + }); + test('changing approval config changes it', async () => { const capped = BASE.replace('approval=true', 'approval=true, approvalMaxDuration="15m"'); expect(await fp(capped)).not.toBe(await fp(BASE)); From dda3c7e06a79fa9f84d1e4778f657a052d1e39a2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 16 Jun 2026 23:39:50 -0700 Subject: [PATCH 23/64] feat(varlock): proxy refresh hot-reloads a running daemon (live policy swap) --- .bumpy/proxy-refresh-hot-reload.md | 7 ++ .../src/content/docs/guides/proxy.mdx | 4 +- .../content/docs/reference/cli-commands.mdx | 2 +- .../varlock/src/cli/commands/proxy.command.ts | 69 ++++++++++++++++--- .../varlock/src/proxy/runtime-proxy.test.ts | 33 +++++++++ packages/varlock/src/proxy/runtime-proxy.ts | 30 +++++++- .../src/proxy/session-registry.test.ts | 8 +++ .../varlock/src/proxy/session-registry.ts | 3 + 8 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 .bumpy/proxy-refresh-hot-reload.md diff --git a/.bumpy/proxy-refresh-hot-reload.md b/.bumpy/proxy-refresh-hot-reload.md new file mode 100644 index 000000000..8702175df --- /dev/null +++ b/.bumpy/proxy-refresh-hot-reload.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`varlock proxy refresh` now hot-reloads a running proxy daemon. + +Editing your schema and running `varlock proxy refresh` re-resolves it in the daemon's trusted context and swaps the live policy — proxy rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. One-shot `proxy run` sessions aren't reloadable (they already re-read the schema each invocation), and refreshing one now reports that instead of silently doing a partial update. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index d6207ca42..5e90035ed 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -213,12 +213,14 @@ varlock proxy status --watch # live updates ## Editing the schema while a session is running -For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started (this prevents an agent from editing the schema to downgrade `@sensitive` and recover real values). After an intentional edit, re-approve it from a trusted shell: +For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started (this prevents an agent from editing the schema to downgrade `@sensitive` and recover real values). After an intentional edit, re-approve it from a **trusted (non-proxied) shell**: ```bash varlock proxy refresh --session ``` +For a `proxy start` daemon this **hot-reloads** the running proxy — it re-resolves your schema in the daemon's trusted context and swaps the live policy (rules, injected secrets, egress mode) without dropping the proxy or restarting your agent. The daemon's terminal prints a confirmation. (A one-shot `proxy run` isn't reloadable — it already re-reads the schema on its next invocation.) + ## Approvals (experimental) :::caution[Experimental — TTY only] diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index d5379ff77..0b3f69403 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -651,7 +651,7 @@ varlock proxy [options] - `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). - `status`: List active proxy sessions. - `audit`: Print a session's request audit log (no secret values). -- `refresh`: Re-validate and reload the schema into a running session after an intentional edit. +- `refresh`: Hot-reload a running `proxy start` daemon after an intentional schema edit — re-resolves and swaps the live policy (rules, injected secrets, egress) without restarting. - `stop`: Stop a session. **Options:** diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 6bbcce292..992340969 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -26,7 +26,9 @@ import { cleanupStaleProxySessions, createProxySessionRecord, markProxySessionEnded, + getProxySessionByToken, getProxySessionExportEnv, + isProcessRunning, listProxySessions, reserveProxySessionIdentity, resolveProxySessionForCommand, @@ -342,6 +344,8 @@ async function createRuntimeAndSession(opts: { approvalProvider: ApprovalProvider; /** Persist session/duration approval scopes as standing grants (interactive sessions only). */ enableApprovalGrants?: boolean; + /** Mark the session as a hot-reloadable daemon (so `proxy refresh` can reload it). */ + reloadable?: boolean; }): Promise<{ runtime: Awaited>; session: ProxySessionRecord; @@ -396,6 +400,7 @@ async function createRuntimeAndSession(opts: { ), ...(opts.entryPaths?.length ? { entryPaths: opts.entryPaths } : {}), ...(opts.command?.length ? { command: opts.command } : {}), + ...(opts.reloadable ? { reloadable: true } : {}), }); return { @@ -686,11 +691,42 @@ async function startAction(ctx: any) { // approvals can be remembered as standing grants. approvalProvider: createTtyApprovalProvider(), enableApprovalGrants: true, + reloadable: true, }); console.log(`Started proxy session ${session.id} (${session.uuid})`); console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); + console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); + + // Hot-reload on SIGHUP (sent by `proxy refresh`): re-resolve the schema in this + // trusted process and swap the live runtime's policy without dropping the proxy. + // Serialized so overlapping refreshes can't interleave. Entry paths are re-read + // from the session record each time, so `proxy refresh --path` is honored. + let reloading: Promise | undefined; + const reload = async () => { + const latest = await getProxySessionByToken(session.uuid).catch(() => undefined); + const paths = latest?.entryPaths ?? ctx.values.path; + const next = await prepareProxyPolicy(paths); + runtime.reconfigure({ + managedItems: next.proxyManagedItems, + rules: next.proxyRules, + egressMode: next.egressMode, + }); + await updateProxySessionRecord(session.uuid, { + schemaFingerprint: next.schemaFingerprint, + egressMode: next.egressMode, + placeholderOverrides: Object.fromEntries( + next.proxyManagedItems.map((item) => [item.key, item.placeholder]), + ), + }); + console.log(`Reloaded proxy session ${session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); + }; + process.on('SIGHUP', () => { + reloading = (reloading ?? Promise.resolve()) + .then(reload) + .catch((error) => console.error(`Proxy reload failed: ${(error as Error).message}`)); + }); let cleanedUp = false; const cleanup = async () => { @@ -800,19 +836,32 @@ async function refreshAction(ctx: any) { throw new CliExitError((error as Error).message); }); + // Only a `proxy start` daemon owns a long-lived runtime that can hot-reload. + // A one-shot `proxy run` captures the schema fresh each invocation, so there's + // nothing to refresh (and SIGHUP would just kill it). + if (!session.reloadable) { + throw new CliExitError( + `Proxy session ${session.id} is not a reloadable daemon.`, + { suggestion: 'Only `varlock proxy start` sessions can be refreshed. A `proxy run` picks up schema changes on its next invocation.' }, + ); + } + if (!isProcessRunning(session.ownerPid)) { + throw new CliExitError(`Proxy session ${session.id} is no longer running.`); + } + + // Validate the new schema here (in this trusted context) so an obviously broken + // edit fails loudly at the call site instead of only in the daemon's terminal. const refreshPaths = ctx.values.path ?? session.entryPaths; - const policy = await prepareProxyPolicy(refreshPaths); + await prepareProxyPolicy(refreshPaths); - await updateProxySessionRecord(session.uuid, { - schemaFingerprint: policy.schemaFingerprint, - egressMode: policy.egressMode, - placeholderOverrides: Object.fromEntries( - policy.proxyManagedItems.map((item) => [item.key, item.placeholder]), - ), - ...(refreshPaths?.length ? { entryPaths: refreshPaths } : {}), - }); + // Persist any changed entry paths so the daemon reloads from them, then signal + // it to re-resolve and hot-swap its live policy (rules, managed items, egress). + if (ctx.values.path?.length) { + await updateProxySessionRecord(session.uuid, { entryPaths: ctx.values.path }); + } + process.kill(session.ownerPid, 'SIGHUP'); - console.log(`Refreshed proxy session ${session.id}.`); + console.log(`Requested reload of proxy session ${session.id}. Watch its terminal (or \`varlock proxy status\`) to confirm.`); } async function stopAction(ctx: any) { diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 304575337..168013c17 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -70,6 +70,39 @@ describe('startLocalProxyRuntime', () => { await runtime.stop(); }); + test('reconfigure() hot-swaps rules/egress on a live runtime', async () => { + const upstream = http.createServer((_req, res) => res.end('ok')); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = upstream.address(); + if (!addr || typeof addr === 'string') throw new Error('failed to start upstream'); + const target = `http://127.0.0.1:${addr.port}/`; + + // Strict + no rules → the upstream host is not allowlisted → blocked. + const runtime = await startLocalProxyRuntime({ managedItems: [], rules: [], egressMode: 'strict' }); + expect((await requestViaProxy(runtime.env.HTTP_PROXY!, target)).statusCode).toBe(403); + + // Reconfigure to allow 127.0.0.1 → the same request now reaches the upstream. + runtime.reconfigure({ + managedItems: [], + rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + egressMode: 'strict', + }); + const allowed = await requestViaProxy(runtime.env.HTTP_PROXY!, target); + expect(allowed.statusCode).toBe(200); + expect(allowed.body).toBe('ok'); + + // Reconfigure back to no rules → blocked again (proves it's not one-way). + runtime.reconfigure({ managedItems: [], rules: [], egressMode: 'strict' }); + expect((await requestViaProxy(runtime.env.HTTP_PROXY!, target)).statusCode).toBe(403); + + await runtime.stop(); + await new Promise((resolve) => { + upstream.close(() => resolve()); + }); + }); + test('refuses to inject a secret into a cleartext (http) connection (Invariant #2)', async () => { let upstreamGotRequest = false; let upstreamAuth = ''; diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 8dff16c31..ba2faa167 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -25,8 +25,21 @@ import type { const LOCALHOST = '127.0.0.1'; +export type ProxyReconfigureInput = { + managedItems: Array; + rules: Array; + egressMode: ProxyEgressMode; +}; + export type ProxyRuntimeContext = { env: NodeJS.ProcessEnv; + /** + * Hot-swap the policy a running proxy enforces (rules, managed items, egress + * mode) without restarting — used by `proxy refresh` to apply schema edits to + * a live daemon. Takes effect on the next request; in-flight requests keep the + * snapshot they already resolved. The proxy address and CA are unchanged. + */ + reconfigure: (next: ProxyReconfigureInput) => void; stop: () => Promise; }; @@ -385,12 +398,18 @@ function buildPathnameAndQuery(input: string, managedItems: Array { + // Mutable so `reconfigure` can hot-swap the enforced policy on a live proxy. + // The request handlers below close over these bindings, so reassigning them + // changes behavior on the next request (in-flight requests already snapshotted). + let managedItems = initialManagedItems; + let rules = initialRules; + let egressMode = initialEgressMode; // Only the public CA cert is written to disk (for child trust). Private keys // — the CA's and every per-host leaf's — stay in memory; see cert-authority.ts. const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); @@ -818,6 +837,11 @@ export async function startLocalProxyRuntime({ CURL_CA_BUNDLE: combinedCaPath, GIT_SSL_CAINFO: combinedCaPath, }, + reconfigure: (next) => { + managedItems = next.managedItems; + rules = next.rules; + egressMode = next.egressMode; + }, stop: async () => { await Promise.all([ new Promise((resolve) => { diff --git a/packages/varlock/src/proxy/session-registry.test.ts b/packages/varlock/src/proxy/session-registry.test.ts index 4ee2dd6f4..1419dd031 100644 --- a/packages/varlock/src/proxy/session-registry.test.ts +++ b/packages/varlock/src/proxy/session-registry.test.ts @@ -51,6 +51,14 @@ describe('proxy session registry (session-as-record)', () => { expect(existsSync(join(getProxySessionDir('uuid-1'), 'session.json'))).toBe(true); }); + test('round-trips the reloadable flag (daemon vs one-shot)', async () => { + await createProxySessionRecord(record({ uuid: 'uuid-daemon', reloadable: true })); + await createProxySessionRecord(record({ uuid: 'uuid-run' })); + const byUuid = Object.fromEntries((await listProxySessions()).map((s) => [s.uuid, s])); + expect(byUuid['uuid-daemon']!.reloadable).toBe(true); + expect(byUuid['uuid-run']!.reloadable).toBeUndefined(); + }); + test('listProxySessions returns only active sessions by default', async () => { await createProxySessionRecord(record()); const active = await listProxySessions(); diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 4065b9f9c..7a569fd65 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -25,6 +25,8 @@ export type ProxySessionRecord = { updatedAt: string; /** Set once the session's process has stopped. A session dir is a durable record; it's marked ended, not deleted. */ endedAt?: string; + /** A long-lived `proxy start` daemon that can hot-reload its policy on `proxy refresh` (via SIGHUP). One-shot `proxy run` sessions are not reloadable. */ + reloadable?: boolean; egressMode: ProxyEgressMode; schemaFingerprint?: string; placeholderOverrides?: Record; @@ -96,6 +98,7 @@ function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | startedAt: String(parsed.startedAt), updatedAt: String(parsed.updatedAt), ...(parsed.endedAt ? { endedAt: String(parsed.endedAt) } : {}), + ...(parsed.reloadable ? { reloadable: true } : {}), egressMode: parsed.egressMode as ProxyEgressMode, ...(parsed.schemaFingerprint ? { schemaFingerprint: String(parsed.schemaFingerprint) } : {}), ...(parsed.placeholderOverrides && typeof parsed.placeholderOverrides === 'object' From a0427d0f9e0b73d4d112a2b858c2ca99b430e566 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 01:06:12 -0700 Subject: [PATCH 24/64] feat(varlock): blocking proxy refresh via file reload channel (replaces SIGHUP) --- .bumpy/proxy-refresh-hot-reload.md | 4 +- .../varlock/src/cli/commands/proxy.command.ts | 216 ++++++++++++++---- .../varlock/src/proxy/reload-channel.test.ts | 63 +++++ packages/varlock/src/proxy/reload-channel.ts | 86 +++++++ 4 files changed, 326 insertions(+), 43 deletions(-) create mode 100644 packages/varlock/src/proxy/reload-channel.test.ts create mode 100644 packages/varlock/src/proxy/reload-channel.ts diff --git a/.bumpy/proxy-refresh-hot-reload.md b/.bumpy/proxy-refresh-hot-reload.md index 8702175df..6fb1c6eef 100644 --- a/.bumpy/proxy-refresh-hot-reload.md +++ b/.bumpy/proxy-refresh-hot-reload.md @@ -2,6 +2,6 @@ varlock: patch --- -`varlock proxy refresh` now hot-reloads a running proxy daemon. +`varlock proxy refresh` hot-reloads a running proxy. -Editing your schema and running `varlock proxy refresh` re-resolves it in the daemon's trusted context and swaps the live policy — proxy rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. One-shot `proxy run` sessions aren't reloadable (they already re-read the schema each invocation), and refreshing one now reports that instead of silently doing a partial update. +Editing your schema and running `varlock proxy refresh` re-resolves it in the proxy's trusted context and swaps the live policy — rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. `refresh` now blocks until the reload completes and then prints how to pick up the new variables (`varlock load` / `varlock run`). Works for both `proxy start` daemons and self-owned `proxy run` sessions. diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 992340969..1ec63435b 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -39,6 +39,14 @@ import { import { PROXY_PARENT_PID_ENV_VAR, } from '../../proxy/env-vars'; +import { + newReloadRequestId, + readReloadRequest, + readReloadResult, + writeReloadRequest, + writeReloadResult, + type ProxyReloadResult, +} from '../../proxy/reload-channel'; import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; import { resetRedactionMap, redactSensitiveConfig } from '../../runtime/env'; @@ -515,6 +523,93 @@ async function resolveAttachSession(ctx: any, schemaFingerprint: string): Promis return candidate; } +/** How often a proxy owner polls for a reload request. */ +const RELOAD_POLL_INTERVAL_MS = 500; +/** How often `proxy refresh` polls for its result, and how long it waits. */ +const REFRESH_POLL_INTERVAL_MS = 250; +const REFRESH_TIMEOUT_MS = 15 * 60_000; + +/** + * Poll the session's reload-request file and, when a new request appears, + * hot-reload the live runtime: re-resolve the schema in this (trusted) process, + * swap the policy via `runtime.reconfigure`, update the session record, and write + * a result the blocking `proxy refresh` reads. Returns `stop()` to clear the poller. + * + * No approval step yet — the reload is applied directly. When the native/phone + * approver lands it inserts a gate at the marked point (the out-of-band approver + * is the real trust boundary; see notes.ignore/proxy-refresh-reload-design.md). + */ +function startReloadServicer(opts: { + session: ProxySessionRecord; + runtime: Awaited>; + defaultEntryPaths?: Array; + log: (message: string) => void; +}): { stop: () => void } { + let lastRequestId: string | undefined; + let initialized = false; + let busy = false; + + const apply = async (requestId: string, entryPaths?: Array): Promise => { + const completedAt = new Date().toISOString(); + try { + const latest = await getProxySessionByToken(opts.session.uuid).catch(() => undefined); + const paths = entryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; + // TODO(approval): when a native/phone approver exists, request approval for + // this schema change here and return status 'denied' if refused. Today the + // reload is applied directly. + const next = await prepareProxyPolicy(paths); + opts.runtime.reconfigure({ + managedItems: next.proxyManagedItems, + rules: next.proxyRules, + egressMode: next.egressMode, + }); + await updateProxySessionRecord(opts.session.uuid, { + schemaFingerprint: next.schemaFingerprint, + egressMode: next.egressMode, + placeholderOverrides: Object.fromEntries( + next.proxyManagedItems.map((item) => [item.key, item.placeholder]), + ), + }); + opts.log(`Reloaded proxy session ${opts.session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); + return { + requestId, status: 'done', completedAt, managedItemCount: next.proxyManagedItems.length, + }; + } catch (error) { + opts.log(`Proxy reload failed: ${(error as Error).message}`); + return { + requestId, status: 'error', completedAt, error: (error as Error).message, + }; + } + }; + + const tick = async () => { + if (busy || !initialized) return; + const request = await readReloadRequest(opts.session.uuid); + if (!request || request.requestId === lastRequestId) return; + lastRequestId = request.requestId; + busy = true; + try { + const result = await apply(request.requestId, request.entryPaths); + await writeReloadResult(opts.session.uuid, result).catch(() => undefined); + } finally { + busy = false; + } + }; + + const timer = setInterval(() => { + tick().catch(() => undefined); + }, RELOAD_POLL_INTERVAL_MS); + timer.unref?.(); + // Ignore a request file left over from a previous run of this session uuid: + // seed lastRequestId before the first tick can fire. + readReloadRequest(opts.session.uuid) + .then((existing) => { lastRequestId = existing?.requestId; }) + .catch(() => undefined) + .finally(() => { initialized = true; }); + + return { stop: () => clearInterval(timer) }; +} + async function runAction(ctx: any) { const commandToRunAsArgs = getRunCommandArgs(); const rawCommand = commandToRunAsArgs[0]!; @@ -551,11 +646,21 @@ async function runAction(ctx: any) { // require-approval requests fail closed. Use `proxy start` (or attach to one) // for interactive approval. approvalProvider: createAutoDenyApprovalProvider(), + reloadable: true, }); session = created.session; statsWriter = created.statsWriter; console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); + // This run owns its proxy, so it services its own `proxy refresh` reloads. + // Notices go to stderr (the child owns stdout). + const reloadServicer = startReloadServicer({ + session, + runtime: created.runtime, + defaultEntryPaths: ctx.values.path, + log: (message) => console.error(message), + }); cleanup = async () => { + reloadServicer.stop(); await created.statsWriter.flushNow(); created.statsWriter.stop(); await created.runtime.stop().catch(() => undefined); @@ -699,39 +804,20 @@ async function startAction(ctx: any) { console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); - // Hot-reload on SIGHUP (sent by `proxy refresh`): re-resolve the schema in this - // trusted process and swap the live runtime's policy without dropping the proxy. - // Serialized so overlapping refreshes can't interleave. Entry paths are re-read - // from the session record each time, so `proxy refresh --path` is honored. - let reloading: Promise | undefined; - const reload = async () => { - const latest = await getProxySessionByToken(session.uuid).catch(() => undefined); - const paths = latest?.entryPaths ?? ctx.values.path; - const next = await prepareProxyPolicy(paths); - runtime.reconfigure({ - managedItems: next.proxyManagedItems, - rules: next.proxyRules, - egressMode: next.egressMode, - }); - await updateProxySessionRecord(session.uuid, { - schemaFingerprint: next.schemaFingerprint, - egressMode: next.egressMode, - placeholderOverrides: Object.fromEntries( - next.proxyManagedItems.map((item) => [item.key, item.placeholder]), - ), - }); - console.log(`Reloaded proxy session ${session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); - }; - process.on('SIGHUP', () => { - reloading = (reloading ?? Promise.resolve()) - .then(reload) - .catch((error) => console.error(`Proxy reload failed: ${(error as Error).message}`)); + // Service `proxy refresh` requests: hot-reload the live policy without dropping + // the proxy. The daemon owns this terminal, so reload notices print here. + const reloadServicer = startReloadServicer({ + session, + runtime, + defaultEntryPaths: ctx.values.path, + log: (message) => console.log(message), }); let cleanedUp = false; const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; + reloadServicer.stop(); await statsWriter.flushNow(); statsWriter.stop(); await runtime.stop().catch(() => undefined); @@ -836,32 +922,80 @@ async function refreshAction(ctx: any) { throw new CliExitError((error as Error).message); }); - // Only a `proxy start` daemon owns a long-lived runtime that can hot-reload. - // A one-shot `proxy run` captures the schema fresh each invocation, so there's - // nothing to refresh (and SIGHUP would just kill it). + // Only a session that owns a live runtime (`proxy start`, or a self-owned + // `proxy run`) can hot-reload. An attached run has no runtime of its own — it + // routes through a daemon, which is the session resolved here anyway. if (!session.reloadable) { throw new CliExitError( - `Proxy session ${session.id} is not a reloadable daemon.`, - { suggestion: 'Only `varlock proxy start` sessions can be refreshed. A `proxy run` picks up schema changes on its next invocation.' }, + `Proxy session ${session.id} is not reloadable.`, + { suggestion: 'Refresh applies to a `proxy start` daemon (or a self-owned `proxy run`).' }, ); } if (!isProcessRunning(session.ownerPid)) { throw new CliExitError(`Proxy session ${session.id} is no longer running.`); } - // Validate the new schema here (in this trusted context) so an obviously broken - // edit fails loudly at the call site instead of only in the daemon's terminal. + // Validate the new schema here (in this context) so an obviously broken edit + // fails loudly at the call site, not only in the owner's logs. const refreshPaths = ctx.values.path ?? session.entryPaths; - await prepareProxyPolicy(refreshPaths); + await prepareProxyPolicy(refreshPaths).catch((error) => { + throw new CliExitError(`Schema does not resolve: ${(error as Error).message}`); + }); + + // Hand the reload to the process that owns the runtime via the file channel, + // then block until it reports a result. (When the native/phone approver lands, + // the wait also spans the approval step — same blocking contract.) + const requestId = newReloadRequestId(); + await writeReloadRequest(session.uuid, { + requestId, + requestedAt: new Date().toISOString(), + ...(ctx.values.path?.length ? { entryPaths: ctx.values.path } : {}), + }); + + let interrupted = false; + const onInterrupt = () => { + interrupted = true; + }; + process.on('SIGINT', onInterrupt); + process.on('SIGTERM', onInterrupt); - // Persist any changed entry paths so the daemon reloads from them, then signal - // it to re-resolve and hot-swap its live policy (rules, managed items, egress). - if (ctx.values.path?.length) { - await updateProxySessionRecord(session.uuid, { entryPaths: ctx.values.path }); + const deadline = Date.now() + REFRESH_TIMEOUT_MS; + let result: ProxyReloadResult | undefined; + try { + console.log(`Requested reload of proxy session ${session.id}; waiting…`); + while (!interrupted) { + const latest = await readReloadResult(session.uuid); + if (latest?.requestId === requestId && latest.status !== 'reloading') { + result = latest; + break; + } + if (Date.now() > deadline) break; + if (!isProcessRunning(session.ownerPid)) { + throw new CliExitError(`Proxy session ${session.id} stopped before the reload completed.`); + } + await new Promise((resolve) => { + setTimeout(resolve, REFRESH_POLL_INTERVAL_MS); + }); + } + } finally { + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onInterrupt); + } + + if (interrupted) { + console.log('Stopped waiting for the reload (it may still complete).'); + return; + } + if (!result) { + throw new CliExitError(`Timed out waiting for proxy session ${session.id} to reload.`); + } + if (result.status === 'error') { + throw new CliExitError(`Proxy reload failed: ${result.error ?? 'unknown error'}`); } - process.kill(session.ownerPid, 'SIGHUP'); - console.log(`Requested reload of proxy session ${session.id}. Watch its terminal (or \`varlock proxy status\`) to confirm.`); + console.log(`✓ Schema change reloaded for proxy session ${session.id}.`); + console.log(' • Commands run via `varlock run -- …` now use the updated variables.'); + console.log(' • Run `varlock load` to see the current variable set (placeholders).'); } async function stopAction(ctx: any) { diff --git a/packages/varlock/src/proxy/reload-channel.test.ts b/packages/varlock/src/proxy/reload-channel.test.ts new file mode 100644 index 000000000..3266712cc --- /dev/null +++ b/packages/varlock/src/proxy/reload-channel.test.ts @@ -0,0 +1,63 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import { join } from 'node:path'; +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; + +import { + newReloadRequestId, + readReloadRequest, + readReloadResult, + writeReloadRequest, + writeReloadResult, +} from './reload-channel'; +import { getProxySessionDir } from './session-registry'; + +// Redirect the session dir into a throwaway XDG_CONFIG_HOME (paths resolve lazily). +let tmpDir: string; +let prevXdg: string | undefined; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(os.tmpdir(), 'varlock-reload-test-')); + prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmpDir; +}); + +afterEach(async () => { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('proxy reload channel', () => { + test('round-trips a request and a result, co-located in the session dir', async () => { + const uuid = 'sess-1'; + const requestId = newReloadRequestId(); + expect(requestId).toMatch(/^[0-9a-f]+$/); + + await writeReloadRequest(uuid, { requestId, requestedAt: '2026-06-16T00:00:00.000Z', entryPaths: ['./envs'] }); + const req = await readReloadRequest(uuid); + expect(req).toMatchObject({ requestId, entryPaths: ['./envs'] }); + expect(existsSync(join(getProxySessionDir(uuid), 'reload-request.json'))).toBe(true); + + await writeReloadResult(uuid, { + requestId, status: 'done', completedAt: '2026-06-16T00:00:01.000Z', managedItemCount: 3, + }); + const res = await readReloadResult(uuid); + expect(res).toMatchObject({ requestId, status: 'done', managedItemCount: 3 }); + }); + + test('returns undefined when no request/result exists', async () => { + expect(await readReloadRequest('missing')).toBeUndefined(); + expect(await readReloadResult('missing')).toBeUndefined(); + }); + + test('a later write replaces the earlier one (last request wins)', async () => { + const uuid = 'sess-2'; + await writeReloadRequest(uuid, { requestId: 'a', requestedAt: '2026-06-16T00:00:00.000Z' }); + await writeReloadRequest(uuid, { requestId: 'b', requestedAt: '2026-06-16T00:00:02.000Z' }); + expect((await readReloadRequest(uuid))?.requestId).toBe('b'); + }); +}); diff --git a/packages/varlock/src/proxy/reload-channel.ts b/packages/varlock/src/proxy/reload-channel.ts new file mode 100644 index 000000000..070e38f1a --- /dev/null +++ b/packages/varlock/src/proxy/reload-channel.ts @@ -0,0 +1,86 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { + mkdir, readFile, rename, writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getProxySessionDir } from './session-registry'; + +/** + * File-based request/result channel between `varlock proxy refresh` and the + * process that owns a live proxy runtime (a `proxy start` daemon or a + * self-owned `proxy run`). The refresh process writes a request; the owner polls + * for it, hot-reloads its policy, and writes back a result the refresh process + * blocks on. Cross-platform (no signals) and holds no secret values. + * + * This is interim plumbing: when the native app / phone approver lands it talks + * to the proxy process directly, inserting an approval step and superseding this + * file handshake. The request → (approve) → reload → result contract is shaped + * for that drop-in. There is intentionally no real authentication here — on a + * shared uid it could not be enforced anyway; the out-of-band approver is the + * actual trust boundary. See notes.ignore/proxy-refresh-reload-design.md. + */ +export type ProxyReloadRequest = { + requestId: string; + requestedAt: string; + /** Entry paths to resolve from; falls back to the session's stored paths. */ + entryPaths?: Array; +}; + +/** `done`/`error` are terminal. A future approver adds `denied` (+ a pending phase). */ +export type ProxyReloadStatus = 'reloading' | 'done' | 'error'; + +export type ProxyReloadResult = { + requestId: string; + status: ProxyReloadStatus; + completedAt: string; + /** Count of managed items after reload (for a friendly confirmation message). */ + managedItemCount?: number; + error?: string; +}; + +function reloadRequestPath(uuid: string): string { + return join(getProxySessionDir(uuid), 'reload-request.json'); +} + +function reloadResultPath(uuid: string): string { + return join(getProxySessionDir(uuid), 'reload-result.json'); +} + +export function newReloadRequestId(): string { + return randomBytes(8).toString('hex'); +} + +/** Write JSON via tmp-file + rename so a reader never sees a torn/partial file. */ +async function writeJsonAtomic(filePath: string, data: unknown): Promise { + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); + const tmp = `${filePath}.${randomBytes(4).toString('hex')}.tmp`; + await writeFile(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 }); + await rename(tmp, filePath); +} + +async function readJson(filePath: string): Promise { + if (!existsSync(filePath)) return undefined; + try { + return JSON.parse(await readFile(filePath, 'utf8')) as T; + } catch { + return undefined; // torn read mid-write; the caller polls again + } +} + +export async function writeReloadRequest(uuid: string, request: ProxyReloadRequest): Promise { + await writeJsonAtomic(reloadRequestPath(uuid), request); +} + +export async function readReloadRequest(uuid: string): Promise { + return readJson(reloadRequestPath(uuid)); +} + +export async function writeReloadResult(uuid: string, result: ProxyReloadResult): Promise { + await writeJsonAtomic(reloadResultPath(uuid), result); +} + +export async function readReloadResult(uuid: string): Promise { + return readJson(reloadResultPath(uuid)); +} From 1b788a97c4ade81e3e2b4c846a70adab5814b14a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 23:28:00 -0700 Subject: [PATCH 25/64] harden proxy secret isolation + fail-closed config, dedup handlers - Resolve every sensitive item to a placeholder (or unset for @proxy=omit) inside a proxied session, so re-running load/printenv/run can't recover a real value. Default flips omit -> placeholder. - Unify proxy-mode detection (env marker -> session token -> ancestry) behind one memoized resolver; fingerprint guard + context guard + load-graph all use it, closing the env -u __VARLOCK_PROXY_CHILD bypass. Record attachedPids so ancestry resolves for attached proxy run sessions. - @proxy(...) rejects unknown options and wrong-typed block/approval/path at load and resolve time instead of silently producing a permissive rule. - Type-aware unique placeholders (url/email/uuid/md5 + string settings). - Sort placeholder->real injection longest-first (substring-collision fix). - Unify the two request handlers into one processProxiedRequest; switch the proxy run redactor to the chunk-buffered writer; drop dead ProxyRule.source, the reloading status, and stale comments. --- .bumpy/proxy-default-omit-sensitive.md | 4 +- .bumpy/proxy-resolution-hardening.md | 9 + .../src/content/docs/guides/proxy.mdx | 13 +- .../varlock/src/cli/commands/proxy.command.ts | 148 ++++---- .../cli/commands/test/proxy.command.test.ts | 62 +++- .../src/cli/helpers/proxy-context-guard.ts | 4 +- .../cli/helpers/proxy-schema-fingerprint.ts | 25 +- packages/varlock/src/env-graph/index.ts | 2 +- .../varlock/src/env-graph/lib/config-item.ts | 21 ++ .../varlock/src/env-graph/lib/data-types.ts | 32 +- .../varlock/src/env-graph/lib/decorators.ts | 35 ++ .../varlock/src/env-graph/lib/env-graph.ts | 59 +++- .../src/env-graph/test/proxy-mode.test.ts | 102 +++++- packages/varlock/src/lib/load-graph.ts | 30 +- .../varlock/src/lib/test/load-graph.test.ts | 35 +- packages/varlock/src/proxy/audit.test.ts | 2 +- packages/varlock/src/proxy/placeholder.ts | 62 ++-- packages/varlock/src/proxy/policy.test.ts | 2 +- packages/varlock/src/proxy/proxy-tls.test.ts | 18 +- packages/varlock/src/proxy/reload-channel.ts | 4 +- .../varlock/src/proxy/runtime-proxy.test.ts | 33 +- packages/varlock/src/proxy/runtime-proxy.ts | 323 ++++++++---------- .../varlock/src/proxy/session-registry.ts | 114 ++++++- packages/varlock/src/proxy/types.ts | 3 - 24 files changed, 761 insertions(+), 381 deletions(-) create mode 100644 .bumpy/proxy-resolution-hardening.md diff --git a/.bumpy/proxy-default-omit-sensitive.md b/.bumpy/proxy-default-omit-sensitive.md index 133b5736f..d5c3ad1ad 100644 --- a/.bumpy/proxy-default-omit-sensitive.md +++ b/.bumpy/proxy-default-omit-sensitive.md @@ -2,6 +2,6 @@ varlock: patch --- -Proxy: omit unhandled sensitive items by default instead of blocking the session. +Proxy: show the agent a placeholder for every sensitive item by default. -Previously `varlock proxy run|start` refused to start if any sensitive item wasn't `@proxy`-managed or `@proxyPassthrough`. Now such items are simply withheld from the proxied child — dropped from both the injected vars and the `__VARLOCK_ENV` blob — with a warning at startup listing the omitted vars. Least privilege by default: the agent only ever sees secrets you explicitly route with `@proxy(...)` (as a placeholder) or `@proxyPassthrough` (the real value), and you no longer have to annotate every other secret just to start a session. Varlock's own `_VARLOCK_*` reserved keys are internal infrastructure and are excluded from this policy entirely (not omitted, not warned). +Inside `varlock proxy run|start`, any `@sensitive` item the agent sees is replaced with a placeholder — the `@proxy(domain=...)`-routed ones (whose real value is injected at the wire) plus every other sensitive item (which simply isn't injected anywhere). The real value never reaches the child. Use `@proxy=passthrough` to inject the real value (escape hatch) or `@proxy=omit` to withhold an item entirely. Varlock's own `_VARLOCK_*` reserved keys are internal infrastructure and are excluded from this policy. diff --git a/.bumpy/proxy-resolution-hardening.md b/.bumpy/proxy-resolution-hardening.md new file mode 100644 index 000000000..a5ae023df --- /dev/null +++ b/.bumpy/proxy-resolution-hardening.md @@ -0,0 +1,9 @@ +--- +varlock: patch +--- + +Proxy: harden secret isolation and fail-closed config validation. + +- A proxied agent can no longer recover a secret by re-resolving the schema (`varlock load`/`printenv`/`run`): inside a proxy session every sensitive item resolves to its placeholder, and `@proxy=omit` items resolve to unset — never the real value. Detection now uses the env marker, the session token, and process ancestry together, so clearing `__VARLOCK_PROXY_CHILD` doesn't bypass the schema-fingerprint guard. +- `@proxy(...)` now rejects unknown options (e.g. a typo like `aproval=true`) and wrong-typed `block`/`approval`/`path` at load time instead of silently producing a permissive rule. +- Type-aware placeholders: `@type=url`/`email`/`uuid`/`md5` get a valid, unique placeholder so SDK format checks pass; all placeholders are unique per item. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 5e90035ed..12454755d 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -114,9 +114,11 @@ STRIPE_SECRET_KEY=sk_live_... By default, varlock applies **least privilege** to the proxied child: - A `@proxy(domain=...)` item → the agent sees a **placeholder**; the real value is injected at the wire. -- A [`@sensitive`](/reference/item-decorators/#sensitive) item with **no** proxy policy → **omitted entirely** from the child (you'll see a warning). The agent gets neither the real value nor a placeholder. +- A [`@sensitive`](/reference/item-decorators/#sensitive) item with **no** proxy policy → the agent sees a **placeholder** too (it just isn't injected anywhere). The real value never reaches the child. - Non-sensitive items → passed through normally. +Because every sensitive item resolves to a placeholder inside a proxied session, an agent can't recover a secret by re-running `varlock load` / `varlock printenv` against the schema — it gets the same placeholder back, not the real value. + To override the default for an item, use the value form of `@proxy`: ```env-spec title=".env.schema" @@ -124,7 +126,8 @@ To override the default for an item, use the value form of `@proxy`: # @proxy=passthrough # inject the REAL value into the child (escape hatch) LEGACY_TOKEN=... -# @proxy=omit # explicitly withhold from the child (no warning) +# @proxy=omit # withhold entirely — absent from the child env, and + # resolves to "unset" (not the real value) if re-resolved UNUSED_SECRET=... ``` @@ -135,8 +138,10 @@ UNUSED_SECRET=... The placeholder the agent sees is chosen in priority order: 1. An explicit [`@placeholder`](/reference/item-decorators/#placeholder) value — always wins. -2. A format derived from the item's [`@type`](/reference/item-decorators/#type) — e.g. `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. -3. A generic fallback (`vlk_placeholder__…`) — varlock **warns** about these, because a generic placeholder can fail an SDK's client-side key-format check (e.g. an `sk-…` prefix assertion) before the request is ever made. +2. A valid-and-unique value derived from the item's [`@type`](/reference/item-decorators/#type) — e.g. `@type=url` → `https://vlk-placeholder-…invalid/`, `@type=email` / `uuid` / `md5` likewise, and `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. +3. A generic fallback (`vlk_placeholder__…`) — for a `@proxy`-routed (wire-injected) item varlock **warns** about these, because a generic placeholder can fail an SDK's client-side key-format check (e.g. an `sk-…` prefix assertion) before the request is ever made. + +Every placeholder is unique per item, so two different secrets can never collide on the wire. If you see the generic-placeholder warning, add an `@placeholder` or a typed format so the placeholder looks valid to the client: diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 1ec63435b..a4ebb435d 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -23,6 +23,7 @@ import { type ApprovalGrantStore, } from '../../proxy/approval-grants'; import { + addProxySessionAttachment, cleanupStaleProxySessions, createProxySessionRecord, markProxySessionEnded, @@ -30,6 +31,7 @@ import { getProxySessionExportEnv, isProcessRunning, listProxySessions, + removeProxySessionAttachment, reserveProxySessionIdentity, resolveProxySessionForCommand, updateProxySessionRecord, @@ -48,8 +50,10 @@ import { type ProxyReloadResult, } from '../../proxy/reload-channel'; import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; +import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; -import { resetRedactionMap, redactSensitiveConfig } from '../../runtime/env'; +import { resetRedactionMap } from '../../runtime/env'; +import { createRedactedStreamWriter } from '../../runtime/lib/redact-stream'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; import { @@ -127,6 +131,14 @@ type PreparedProxyPolicy = { proxyManagedItems: Array; proxyRules: Array; egressMode: 'permissive' | 'strict'; + /** + * Every sensitive item key → the placeholder the child sees (managed/wire items + * plus every other sensitive item, which gets a placeholder by default). Stored + * on the session so a proxied re-resolution yields the same placeholders. + */ + placeholderByKey: Record; + /** Keys explicitly `@proxy=omit` — withheld from the child env entirely. */ + omittedKeys: Array; }; const EMPTY_PROXY_SESSION_STATS: ProxySessionStats = { @@ -202,34 +214,46 @@ function getProxyValueMode( } /** - * Keys withheld (omitted) from the proxied child. An item is omitted when it's - * not `@proxy`-managed (placeholder) and not `@proxy=passthrough` (real value), - * and is either sensitive (omitted by default — least privilege) or explicitly - * marked `@proxy=omit`. `_VARLOCK_*` reserved keys are internal infra, never - * omitted. Returns `{ explicit }` so the caller can warn only about implicit - * omits ("no proxy policy set"). + * The proxy view of the child env, computed from the resolved (real-value) graph: + * - `placeholderByKey`: every sensitive item the child should see a placeholder + * for — the `@proxy`-managed/wire items plus, by default, every other sensitive + * item (least privilege: the agent never sees a real secret unless explicitly + * opted in). Managed placeholders are passed in so the two share uniqueness. + * - `omittedKeys`: items explicitly marked `@proxy=omit` — withheld entirely. + * + * `@proxy=passthrough` and non-sensitive items keep their real values. + * `_VARLOCK_*` reserved keys are internal infra and never touched. */ -export function getProxyOmittedKeys( +export async function computeProxyChildView( envGraph: Awaited>, proxyManagedItems: Array, -): Array<{ key: string; explicit: boolean }> { +): Promise<{ placeholderByKey: Record; omittedKeys: Array }> { const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); - const omitted: Array<{ key: string; explicit: boolean }> = []; + const placeholderByKey: Record = {}; + for (const item of proxyManagedItems) placeholderByKey[item.key] = item.placeholder; + + // Seed uniqueness with the managed placeholders so default-sensitive placeholders + // can't collide (collisions would corrupt wire scrubbing). + const usedPlaceholders = new Set(proxyManagedItems.map((item) => item.placeholder)); + const omittedKeys: Array = []; + for (const key of envGraph.sortedConfigKeys) { if (isVarlockReservedKey(key)) continue; + if (managedKeys.has(key)) continue; const item = envGraph.configSchema[key]; if (!item || item.resolvedValue === undefined) continue; - if (managedKeys.has(key)) continue; const mode = getProxyValueMode(item); if (mode === 'passthrough') continue; // inject the real value if (mode === 'omit') { - omitted.push({ key, explicit: true }); + omittedKeys.push(key); continue; } if (!item.isSensitive) continue; // non-sensitive with no policy → injected normally - omitted.push({ key, explicit: false }); // sensitive default → omit (and warn) + const { placeholder } = await generateProxyPlaceholderForItem(item, usedPlaceholders); + placeholderByKey[key] = placeholder; // sensitive default → placeholder } - return omitted; + + return { placeholderByKey, omittedKeys }; } function quoteForShell(value: string): string { @@ -305,35 +329,21 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise !o.explicit).map((o) => o.key); - if (warnKeys.length) { - console.error( - `⚠️ The following sensitive var(s) were omitted from the proxied child because no proxy policy is set for them: ${warnKeys.join(', ')}`, - ); - console.error( - ' Add @proxy(...) to route a value through the proxy (agent sees a placeholder), ' - + '@proxy=passthrough to inject the real value, or @proxy=omit to omit it explicitly.', - ); - } - } + // Least privilege by default: every sensitive item the child sees is a + // placeholder (managed/wire items plus the rest), unless explicitly opted out + // with @proxy=passthrough (real value) or @proxy=omit (withheld entirely). + const { placeholderByKey, omittedKeys } = await computeProxyChildView(envGraph, proxyManagedItems); - for (const managedItem of proxyManagedItems) { - resolvedEnv[managedItem.key] = managedItem.placeholder; - if (serializedGraph.config[managedItem.key]) { - serializedGraph.config[managedItem.key].value = managedItem.placeholder; + for (const [key, placeholder] of Object.entries(placeholderByKey)) { + resolvedEnv[key] = placeholder; + if (serializedGraph.config[key]) { + serializedGraph.config[key].value = placeholder; } } + for (const key of omittedKeys) { + delete resolvedEnv[key]; + delete serializedGraph.config[key]; + } return { resolvedEnv, @@ -342,6 +352,8 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise [item.key, item.placeholder]), - ), + placeholderOverrides: opts.policy.placeholderByKey, + ...(opts.policy.omittedKeys.length ? { omittedKeys: opts.policy.omittedKeys } : {}), stats: cloneSessionStats(statsWriter.stats), env: Object.fromEntries( Object.entries(runtime.env).filter((entry): entry is [string, string] => !!entry[1]), @@ -550,7 +561,6 @@ function startReloadServicer(opts: { let busy = false; const apply = async (requestId: string, entryPaths?: Array): Promise => { - const completedAt = new Date().toISOString(); try { const latest = await getProxySessionByToken(opts.session.uuid).catch(() => undefined); const paths = entryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; @@ -566,18 +576,17 @@ function startReloadServicer(opts: { await updateProxySessionRecord(opts.session.uuid, { schemaFingerprint: next.schemaFingerprint, egressMode: next.egressMode, - placeholderOverrides: Object.fromEntries( - next.proxyManagedItems.map((item) => [item.key, item.placeholder]), - ), + placeholderOverrides: next.placeholderByKey, + omittedKeys: next.omittedKeys, }); opts.log(`Reloaded proxy session ${opts.session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); return { - requestId, status: 'done', completedAt, managedItemCount: next.proxyManagedItems.length, + requestId, status: 'done', completedAt: new Date().toISOString(), managedItemCount: next.proxyManagedItems.length, }; } catch (error) { opts.log(`Proxy reload failed: ${(error as Error).message}`); return { - requestId, status: 'error', completedAt, error: (error as Error).message, + requestId, status: 'error', completedAt: new Date().toISOString(), error: (error as Error).message, }; } }; @@ -628,15 +637,26 @@ async function runAction(ctx: any) { let cleanup: () => Promise; if (attachSession) { - // Use the daemon's authoritative placeholders so the child's values are - // exactly what the running proxy expects to substitute. + // Use the daemon's authoritative view so the child's values are exactly what + // the running proxy expects: its placeholders for sensitive items, and its + // omitted keys withheld entirely. for (const [key, placeholder] of Object.entries(attachSession.placeholderOverrides ?? {})) { policy.resolvedEnv[key] = placeholder; if (policy.serializedGraph.config[key]) policy.serializedGraph.config[key].value = placeholder; } + for (const key of attachSession.omittedKeys ?? []) { + delete policy.resolvedEnv[key]; + delete policy.serializedGraph.config[key]; + } session = attachSession; + // Register this `proxy run` process on the (shared daemon) session so ancestry + // detection recognizes the agent as proxied even if it scrubs the env markers — + // the daemon's ownerPid isn't in the agent's parent chain, but this pid is. + await addProxySessionAttachment(session.uuid, process.pid); console.error(`Attached to proxy session ${session.id}. Approval prompts (if any) appear in its terminal.`); - cleanup = async () => undefined; // nothing to tear down — the session is the daemon's + cleanup = async () => { + await removeProxySessionAttachment(session.uuid, process.pid).catch(() => undefined); + }; } else { const created = await createRuntimeAndSession({ policy, @@ -708,11 +728,6 @@ async function runAction(ctx: any) { resetRedactionMap(redactionGraph); } - const writeRedacted = (stream: NodeJS.WriteStream, chunk: Buffer | string) => { - const str = chunk.toString(); - stream.write(redactLogs ? redactSensitiveConfig(str) : str); - }; - if (noRedactStdout) { commandProcess = exec(rawCommand, commandArgsOnly, { stdio: 'inherit', @@ -740,8 +755,20 @@ async function runAction(ctx: any) { stderr: 'pipe', env: redactEnv, }); - commandProcess.stdout?.on('data', (chunk: Buffer | string) => writeRedacted(process.stdout, chunk)); - commandProcess.stderr?.on('data', (chunk: Buffer | string) => writeRedacted(process.stderr, chunk)); + // Pipe child output through the shared chunk-boundary-buffered redactor (the + // same one `varlock run` uses) so a secret split across two chunks is still + // caught — the proxy must redact the real values it injects at the wire. + if (redactLogs) { + const stdoutWriter = createRedactedStreamWriter(process.stdout); + const stderrWriter = createRedactedStreamWriter(process.stderr); + commandProcess.stdout?.on('data', stdoutWriter.write); + commandProcess.stdout?.on('close', stdoutWriter.flush); + commandProcess.stderr?.on('data', stderrWriter.write); + commandProcess.stderr?.on('close', stderrWriter.flush); + } else { + commandProcess.stdout?.on('data', (chunk: Buffer | string) => process.stdout.write(chunk)); + commandProcess.stderr?.on('data', (chunk: Buffer | string) => process.stderr.write(chunk)); + } } if (commandProcess.pid && !attachSession) { @@ -965,7 +992,8 @@ async function refreshAction(ctx: any) { console.log(`Requested reload of proxy session ${session.id}; waiting…`); while (!interrupted) { const latest = await readReloadResult(session.uuid); - if (latest?.requestId === requestId && latest.status !== 'reloading') { + if (latest?.requestId === requestId) { + // `done`/`error` are both terminal — the servicer only writes a result once. result = latest; break; } diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index f405d2ff4..bc6eb5a82 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; -import { getProxyOmittedKeys, isCwdWithin } from '../proxy.command.js'; +import { computeProxyChildView, isCwdWithin } from '../proxy.command.js'; async function loadGraph(envFile: string) { const graph = new EnvGraph(); @@ -12,13 +12,13 @@ async function loadGraph(envFile: string) { return graph; } -async function omittedKeys(graph: EnvGraph) { +async function childView(graph: EnvGraph) { const managedItems = await graph.getProxyManagedItems(); - return getProxyOmittedKeys(graph, managedItems); + return computeProxyChildView(graph, managedItems); } -describe('getProxyOmittedKeys', () => { - test('omits an unmanaged sensitive key by default (implicit — warned)', async () => { +describe('computeProxyChildView', () => { + test('gives an unmanaged sensitive key a placeholder by default (not omitted)', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -31,10 +31,32 @@ describe('getProxyOmittedKeys', () => { UNMANAGED_SECRET=secret-unmanaged `); - expect(await omittedKeys(graph)).toEqual([{ key: 'UNMANAGED_SECRET', explicit: false }]); + const view = await childView(graph); + // both the managed and the default-sensitive item get a placeholder + expect(Object.keys(view.placeholderByKey).sort()).toEqual(['PROXIED_SECRET', 'UNMANAGED_SECRET']); + expect(view.placeholderByKey.UNMANAGED_SECRET).not.toBe('secret-unmanaged'); + expect(view.omittedKeys).toEqual([]); + // non-sensitive baseline is left alone + expect(view.placeholderByKey.BASELINE).toBeUndefined(); }); - test('does not omit a sensitive key marked @proxy=passthrough', async () => { + test('placeholders are unique across items (no scrub collisions)', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @sensitive + A_SECRET=aaa + + # @sensitive + B_SECRET=bbb + `); + + const view = await childView(graph); + const placeholders = Object.values(view.placeholderByKey); + expect(new Set(placeholders).size).toBe(placeholders.length); + }); + + test('does not placeholder/omit a sensitive key marked @proxy=passthrough', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -43,7 +65,9 @@ describe('getProxyOmittedKeys', () => { PASS_SECRET=secret-allowed `); - expect(await omittedKeys(graph)).toEqual([]); + const view = await childView(graph); + expect(view.placeholderByKey.PASS_SECRET).toBeUndefined(); + expect(view.omittedKeys).toEqual([]); }); test('omits a key marked @proxy=omit explicitly (regardless of sensitivity)', async () => { @@ -58,13 +82,13 @@ describe('getProxyOmittedKeys', () => { SECRET_OMITTED=secret `); - expect(await omittedKeys(graph)).toEqual([ - { key: 'PLAIN_BUT_OMITTED', explicit: true }, - { key: 'SECRET_OMITTED', explicit: true }, - ]); + const view = await childView(graph); + expect(view.omittedKeys.sort()).toEqual(['PLAIN_BUT_OMITTED', 'SECRET_OMITTED']); + expect(view.placeholderByKey.PLAIN_BUT_OMITTED).toBeUndefined(); + expect(view.placeholderByKey.SECRET_OMITTED).toBeUndefined(); }); - test('does not omit varlock-reserved (_VARLOCK_*) keys — they are internal infra', async () => { + test('does not touch varlock-reserved (_VARLOCK_*) keys — they are internal infra', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -75,10 +99,13 @@ describe('getProxyOmittedKeys', () => { USER_SECRET=some-secret `); - expect(await omittedKeys(graph)).toEqual([{ key: 'USER_SECRET', explicit: false }]); + const view = await childView(graph); + expect(view.placeholderByKey._VARLOCK_ENV_KEY).toBeUndefined(); + expect(Object.keys(view.placeholderByKey)).toEqual(['USER_SECRET']); + expect(view.omittedKeys).toEqual([]); }); - test('does not omit non-sensitive keys with no policy', async () => { + test('does not placeholder/omit non-sensitive keys with no policy', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- @@ -89,7 +116,10 @@ describe('getProxyOmittedKeys', () => { PROXIED_SECRET=secret-proxied `); - expect(await omittedKeys(graph)).toEqual([]); + const view = await childView(graph); + expect(view.placeholderByKey.PLAIN).toBeUndefined(); + expect(Object.keys(view.placeholderByKey)).toEqual(['PROXIED_SECRET']); + expect(view.omittedKeys).toEqual([]); }); }); diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index af29c6e13..1a48e8305 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -1,6 +1,6 @@ import { CliExitError } from './exit-error'; import { createDebug } from '../../lib/debug'; -import { resolveActiveProxySession } from '../../proxy/session-registry'; +import { getActiveProxySession } from '../../proxy/session-registry'; import { PROXY_CHILD_ENV_VAR, } from '../../proxy/env-vars'; @@ -26,7 +26,7 @@ export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boole async function isInProxyContext(env: NodeJS.ProcessEnv): Promise { if (isProxyChildProcess(env)) return true; - const session = await resolveActiveProxySession(env); + const session = await getActiveProxySession(env); if (session) { debug( 'proxy context detected via process ancestry with the env marker absent ' diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index d7784c6b9..81f5c3939 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -11,12 +11,10 @@ import { import type { EnvGraph } from '../../env-graph'; import { CliExitError } from './exit-error'; -import { getProxySessionByToken } from '../../proxy/session-registry'; +import { getActiveProxySession } from '../../proxy/session-registry'; import { PROXY_CHILD_ENV_VAR, PROXY_SCHEMA_FINGERPRINT_ENV_VAR, - PROXY_SESSION_ID_ENV_VAR, - PROXY_SESSION_UUID_ENV_VAR, } from '../../proxy/env-vars'; /** @@ -102,22 +100,25 @@ export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { * * No-ops outside a proxied context, and when there's no stored fingerprint to * compare against (fails open rather than blocking unrelated commands). + * + * Detection goes through the shared `getActiveProxySession` resolver (env marker + * → session token → process ancestry), so clearing `__VARLOCK_PROXY_CHILD` alone + * does not bypass the guard — the session token and ancestry still resolve the + * session whose fingerprint we enforce against. */ export async function enforceProxySchemaFingerprint( envGraph: EnvGraph, env: NodeJS.ProcessEnv = process.env, ): Promise { - if (env[PROXY_CHILD_ENV_VAR] !== '1') return; + const session = await getActiveProxySession(env).catch(() => undefined); - // Prefer the session record as source of truth; fall back to the - // env-exported fingerprint if the registry can't be read. - const sessionToken = env[PROXY_SESSION_ID_ENV_VAR] ?? env[PROXY_SESSION_UUID_ENV_VAR]; - let expected: string | undefined; - if (sessionToken) { - const session = await getProxySessionByToken(sessionToken).catch(() => undefined); - expected = session?.schemaFingerprint; + // Prefer the resolved session's fingerprint as source of truth; fall back to + // the env-exported fingerprint only when we know we're in a proxy child (marker + // present) but the registry couldn't be read. + let expected = session?.schemaFingerprint; + if (!expected && env[PROXY_CHILD_ENV_VAR] === '1') { + expected = env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR]; } - expected ??= env[PROXY_SCHEMA_FINGERPRINT_ENV_VAR]; if (!expected) return; const actual = buildProxySchemaFingerprint(envGraph); diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts index f632d490b..7c53457b5 100644 --- a/packages/varlock/src/env-graph/index.ts +++ b/packages/varlock/src/env-graph/index.ts @@ -1,6 +1,6 @@ export { loadEnvGraph } from './lib/loader'; -export { EnvGraph, type SerializedEnvGraph } from './lib/env-graph'; +export { EnvGraph, type SerializedEnvGraph, type ProxyResolutionView } from './lib/env-graph'; export { FileBasedDataSource, DotEnvFileDataSource, DirectoryDataSource, MultiplePathsContainerDataSource, } from './lib/data-source'; diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 4f021e4da..348e30220 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -674,6 +674,27 @@ export class ConfigItem { await this.processRequired(); await this.processSensitive(); + // Proxy-child resolution view: inside a `varlock proxy` session, a sensitive + // item is forced to its placeholder (or omitted) instead of resolving the real + // value — so re-running `varlock load`/`printenv`/`run` can never surface a + // secret the proxy is meant to hide. Runs after decorators (so isSensitive, + // isRequired, etc. are correct for serialization) but short-circuits value + // resolution, coercion, validation and the required check: the real value was + // already validated upstream by the proxy daemon. + const proxyDirective = this.envGraph.proxyResolutionView?.[this.key]; + if (proxyDirective) { + this.isResolved = true; + if (proxyDirective.kind === 'placeholder') { + this.resolvedRawValue = proxyDirective.value; + this.resolvedValue = proxyDirective.value; + } else { + this.resolvedRawValue = undefined; + this.resolvedValue = undefined; + } + this.isValidated = true; + return; + } + // Resolver functions like varlock() and keychain() imply sensitivity — // override defaults but respect explicit per-item @sensitive=false / @public if (this.valueResolver?.def?.impliesSensitive && !this._sensitiveExplicitlySet) { diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 93454aab1..e32d473ff 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import _ from '@env-spec/utils/my-dash'; import { type FallbackIfUnknown } from '@env-spec/utils/type-utils'; import { CoercionError, ValidationError } from './errors'; @@ -31,8 +32,15 @@ type EnvGraphDataTypeDef MaybePromise<(true | undefined | void | Error | Array)>; - /** optional placeholder generator used by proxy mode */ - generatePlaceholder?: (value: CoerceReturnType | string | undefined) => string | undefined; + /** + * Optional placeholder generator used by proxy mode. Receives a unique, + * format-safe `seed` (lowercase alphanumerics + hyphens, derived from the item + * key) and should return a value that (a) is a valid instance of this type so it + * passes an SDK's format checks, and (b) embeds the seed so distinct items get + * distinct placeholders (required so wire scrubbing can't confuse two secrets). + * Return undefined when no valid-and-unique form exists for this type. + */ + generatePlaceholder?: (seed: string) => string | undefined; // asyncValidate? - async validation function, meant to be called more sparingly // for example, when could validate an API key is currently valid @@ -73,8 +81,8 @@ export class EnvGraphDataType { get isSensitive() { return this.def.sensitive; } get isInternal() { return this.def.internal; } get docsEntries() { return this.def.docs; } - generatePlaceholder(val: any) { - return this.def.generatePlaceholder?.(val); + generatePlaceholder(seed: string) { + return this.def.generatePlaceholder?.(seed); } /** @internal */ @@ -140,6 +148,11 @@ function coerceToNumber(rawVal: any) { return numVal; } +/** Deterministic lowercase hex derived from a placeholder seed (for uuid/md5 forms). */ +function hexFromSeed(seed: string): string { + return createHash('sha256').update(seed).digest('hex'); +} + const StringDataType = createEnvGraphDataType( (settings?: { /** The minimum length of the string. */ @@ -327,6 +340,8 @@ const UrlDataType = createEnvGraphDataType( }) => ({ name: 'url', icon: 'carbon:url', + // A valid, unique, non-routable URL (`.invalid` is reserved, RFC 2606). + generatePlaceholder: (seed) => `https://${seed}.invalid/`, coerce(rawVal) { const val = coerceToString(rawVal); if (settings?.prependHttps && !val.startsWith('https://')) return `https://${val}`; @@ -423,6 +438,8 @@ const EmailDataType = createEnvGraphDataType( name: 'email', icon: 'iconoir:at-sign', typeDescription: 'standard email address', + // A valid, unique address at the reserved `.invalid` TLD (RFC 2606). + generatePlaceholder: (seed) => `${seed}@placeholder.invalid`, coerce(rawVal) { let val = coerceToString(rawVal); if (settings?.normalize) val = val.toLowerCase(); @@ -526,6 +543,11 @@ const UuidDataType = createEnvGraphDataType({ name: 'uuid', icon: 'mdi:identifier', typeDescription: 'UUID string V1-V5 per RFC4122, including NIL', + // A deterministic, unique, valid v4-shaped UUID derived from the seed. + generatePlaceholder: (seed) => { + const hex = hexFromSeed(seed); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; + }, validate(val) { const result = UUID_REGEX.test(val); if (result) return true; @@ -537,6 +559,8 @@ const MD5_REGEX = /^[a-f0-9]{32}$/; const Md5DataType = createEnvGraphDataType({ name: 'md5', typeDescription: 'MD5 hash string', + // A deterministic, unique, valid 32-hex string derived from the seed. + generatePlaceholder: (seed) => hexFromSeed(seed).slice(0, 32), validate(val) { const result = MD5_REGEX.test(val); if (result) return true; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index aea29784a..dd08a824d 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -341,13 +341,48 @@ 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', 'approvalEach', 'approvalMaxDuration'] as const; + +/** A static boolean option (`block`/`approval`) must be a real boolean — a quoted + * `"true"` or `1` is a misconfiguration that would otherwise silently drop the + * option (turning a deny/approval rule into a plain allow). Dynamic expressions + * are validated at resolve time. */ +function assertProxyBooleanArg(resolver: Resolver | undefined, option: string): void { + if (!resolver?.isStatic) return; + if (typeof resolver.staticValue !== 'boolean') { + throw new SchemaError(`@proxy: ${option} must be a boolean (true or false), not ${JSON.stringify(resolver.staticValue)}`); + } +} + +/** A static `path` must be a single non-empty string (not an array/number). */ +function assertProxyStringArg(resolver: Resolver | undefined, option: string): void { + if (!resolver?.isStatic) return; + if (typeof resolver.staticValue !== 'string' || !resolver.staticValue.trim()) { + throw new SchemaError(`@proxy: ${option} must be a non-empty string`); + } +} + function validateProxyFunctionArgs(argsVal: Resolver): void { if (!argsVal.objArgs?.domain) { throw new SchemaError('@proxy: missing required "domain" option'); } + + // Reject unknown options so a typo (e.g. `aproval=true`, `blok=true`) fails loudly + // instead of silently producing a permissive rule. + for (const key of Object.keys(argsVal.objArgs)) { + if (!VALID_PROXY_OPTIONS.includes(key as typeof VALID_PROXY_OPTIONS[number])) { + throw new SchemaError( + `@proxy: unknown option "${key}". Valid options: ${VALID_PROXY_OPTIONS.join(', ')}`, + ); + } + } + assertProxyStringListArg(argsVal.objArgs.domain, 'domain', false); assertProxyStringListArg(argsVal.objArgs?.method, 'method', false); assertProxyStringListArg(argsVal.objArgs?.keys, 'keys', true); + assertProxyStringArg(argsVal.objArgs?.path, 'path'); + assertProxyBooleanArg(argsVal.objArgs?.block, 'block'); + assertProxyBooleanArg(argsVal.objArgs?.approval, 'approval'); if (argsVal.arrArgs?.length) { throw new SchemaError('@proxy: positional args are not supported - use keys=[ITEM_A, ITEM_B] to attach items'); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 00b5ec8d1..c667126a4 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -27,8 +27,9 @@ import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; import { isVarlockReservedKey } from './reserved-vars'; import { buildOverrideProvenanceMetadata, type OverrideProvenanceMetadata } from '../../lib/injected-env-provenance'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; -import type { - ProxyApprovalEach, ProxyEgressMode, ProxyManagedItem, ProxyRule, +import { + PROXY_APPROVAL_EACH_VALUES, + type ProxyApprovalEach, type ProxyEgressMode, type ProxyManagedItem, type ProxyRule, } from '../../proxy/types'; import { parseDuration } from '../../lib/duration'; @@ -80,6 +81,15 @@ export type SerializedEnvGraph = { errors?: SerializedEnvGraphErrors; }; +/** + * Per-item directive applied during resolution inside a proxy-child context: + * substitute a placeholder for the (sensitive) value, or omit it entirely. + */ +export type ProxyResolutionView = Record< + string, + { kind: 'placeholder'; value: string } | { kind: 'omit' } +>; + /** container of the overall graph and current resolution attempt / values */ export class EnvGraph { // TODO: not sure if this should be the graph of _everything_ in a workspace/project @@ -104,6 +114,16 @@ export class EnvGraph { /** place to store process.env overrides */ overrideValues: Record = {}; + /** + * Proxy-child resolution view: when a graph is loaded inside a `varlock proxy` + * session, each sensitive item is forced to a placeholder (or omitted) at + * resolution time so re-resolving the schema can never surface the real value. + * Set by `load-graph` from the active session's record. The real values were + * already validated by the proxy daemon, so these short-circuit coerce/validate + * and the required check. Empty/undefined outside a proxied context. + */ + proxyResolutionView?: ProxyResolutionView; + /** config item key of env flag (toggles env-specific data sources enabled) */ envFlagKey?: string; /** graph-level fallback value for environment flag */ @@ -892,9 +912,40 @@ export class EnvGraph { .filter(Boolean); } + /** + * Validate a *resolved* `@proxy(...)` arg object — catches misconfigurations + * that only surface after dynamic resolution (where the static load-time + * validator can't see the value). Fail loud rather than silently dropping a + * security-relevant option (a dropped `block`/`approval` is a permissive rule). + */ + private static validateResolvedProxyObj(obj: any): void { + if (obj?.block !== undefined && !_.isBoolean(obj.block)) { + throw new SchemaError(`@proxy: block must resolve to a boolean, got ${JSON.stringify(obj.block)}`); + } + if (obj?.approval !== undefined && !_.isBoolean(obj.approval)) { + throw new SchemaError(`@proxy: approval must resolve to a boolean, got ${JSON.stringify(obj.approval)}`); + } + if (obj?.path !== undefined && !_.isString(obj.path)) { + throw new SchemaError(`@proxy: path must resolve to a string, got ${JSON.stringify(obj.path)}`); + } + const eachOk = _.isString(obj?.approvalEach) + && PROXY_APPROVAL_EACH_VALUES.includes(obj.approvalEach as ProxyApprovalEach); + if (obj?.approvalEach !== undefined && !eachOk) { + throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); + } + if (obj?.approvalMaxDuration !== undefined) { + try { + parseDuration(obj.approvalMaxDuration); + } catch { + throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); + } + } + } + /** * Approval fields for a rule, from a resolved `@proxy(...)` arg object. * Approval is required if `approval=true` or any approval config prop is set. + * Assumes the object has already passed `validateResolvedProxyObj`. */ private static buildProxyApprovalFields(obj: any): Partial { const approvalEach = _.isString(obj?.approvalEach) ? (obj.approvalEach as ProxyApprovalEach) : undefined; @@ -915,12 +966,12 @@ export class EnvGraph { // detached rules from root-level @proxy(...) for (const rootProxyDec of this.getRootDecFns('proxy')) { const resolved = await rootProxyDec.resolve(); + EnvGraph.validateResolvedProxyObj(resolved?.obj); const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; const method = EnvGraph.normalizeStringList(resolved?.obj?.method); rules.push({ - source: 'detached', domain, itemKeys: EnvGraph.normalizeStringList(resolved?.obj?.keys), ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), @@ -935,6 +986,7 @@ export class EnvGraph { const item = this.configSchema[itemKey]; for (const itemProxyDec of item.getDecFns('proxy')) { const resolved = await itemProxyDec.resolve(); + EnvGraph.validateResolvedProxyObj(resolved?.obj); const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; @@ -942,7 +994,6 @@ export class EnvGraph { const extraKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); const itemKeys = _.uniq([itemKey, ...extraKeys]); rules.push({ - source: 'attached', domain, itemKeys, ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 06f8c8716..8da663767 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -42,12 +42,10 @@ describe('proxy decorators', () => { const rules = await graph.getProxyRules(); expect(rules).toMatchObject([ { - source: 'detached', domain: ['api.example.com'], itemKeys: [], }, { - source: 'attached', domain: ['api.stripe.com'], itemKeys: ['STRIPE_KEY'], }, @@ -64,7 +62,6 @@ describe('proxy decorators', () => { expect(await graph.getProxyRules()).toMatchObject([ { - source: 'attached', domain: ['api.a.com', 'api.b.com'], method: ['GET', 'POST'], itemKeys: ['API_KEY'], @@ -85,7 +82,7 @@ describe('proxy decorators', () => { `); const rules = await graph.getProxyRules(); - expect(rules).toMatchObject([{ source: 'detached', domain: ['api.example.com'], itemKeys: ['STRIPE_KEY', 'WEBHOOK_SECRET'] }]); + expect(rules).toMatchObject([{ domain: ['api.example.com'], itemKeys: ['STRIPE_KEY', 'WEBHOOK_SECRET'] }]); // and those keys become managed (placeholders injected) const managed = await graph.getProxyManagedItems(); expect(managed.map((i) => i.key).sort()).toEqual(['STRIPE_KEY', 'WEBHOOK_SECRET']); @@ -113,6 +110,39 @@ describe('proxy decorators', () => { expect(errors.some((e) => /keys must be an array literal/.test(e.message))).toBe(true); }); + test('an unknown option is rejected (typo fails loud, not silently permissive)', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", aproval=true) + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /unknown option "aproval"/.test(e.message))).toBe(true); + }); + + test('block must be a real boolean, not a quoted string', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", block="true") + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /block must be a boolean/.test(e.message))).toBe(true); + }); + + test('path must be a string, not an array literal', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", path=[a, b]) + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /path must be a non-empty string/.test(e.message))).toBe(true); + }); + test('a header-level (detached) @proxy is not rejected as a misplaced item decorator', async () => { const graph = await loadGraph(outdent` # @enableProxy(egress="strict") @@ -130,10 +160,8 @@ describe('proxy decorators', () => { // ...and both detached rules are collected, including the approve rule. const rules = await graph.getProxyRules(); expect(rules).toMatchObject([ - { source: 'detached', domain: ['api.a.com'] }, - { - source: 'detached', domain: ['api.b.com'], path: '/admin/**', approval: true, - }, + { domain: ['api.a.com'] }, + { domain: ['api.b.com'], path: '/admin/**', approval: true }, ]); }); @@ -234,9 +262,11 @@ describe('proxy decorators', () => { const managed = await graph.getProxyManagedItems(); const byKey = Object.fromEntries(managed.map((item) => [item.key, item])); - // Explicit @placeholder wins; @type constraints derive a format-shaped placeholder. + // Explicit @placeholder wins; @type constraints derive a format-shaped + // placeholder honoring startsWith + isLength, while staying unique. expect(byKey.EXPLICIT_KEY?.placeholder).toBe('sk_test_explicit'); - expect(byKey.TYPE_KEY?.placeholder).toBe('tok_00000000'); + expect(byKey.TYPE_KEY?.placeholder).toMatch(/^tok_[0-9a-f]{8}$/); + expect(byKey.TYPE_KEY?.placeholder).toHaveLength(12); expect(byKey.EXPLICIT_KEY?.placeholderIsGenericFallback).toBeFalsy(); expect(byKey.TYPE_KEY?.placeholderIsGenericFallback).toBeFalsy(); @@ -249,3 +279,55 @@ describe('proxy decorators', () => { expect(byKey.NO_HINT_KEY?.realValue).toBe('whatever_real_secret'); }); }); + +describe('proxy resolution view (proxied re-resolution)', () => { + // Build a graph but apply a proxy view BEFORE resolving, mimicking a proxied + // child re-running `varlock load`/`printenv`. The real value must never surface. + async function loadWithView( + envFile: string, + view: NonNullable, + ) { + const graph = new EnvGraph(); + const source = new DotEnvFileDataSource('.env.schema', { overrideContents: envFile }); + await graph.setRootDataSource(source); + await graph.finishLoad(); + graph.proxyResolutionView = view; + await graph.resolveEnvValues(); + return graph; + } + + test('forces a placeholder for a sensitive item and skips coerce/validate', async () => { + const graph = await loadWithView( + outdent` + # --- + # @sensitive @type=number + NUM_SECRET=42 + `, + { NUM_SECRET: { kind: 'placeholder', value: 'vlk_placeholder_NUM_SECRET_abcd1234' } }, + ); + + const item = graph.configSchema.NUM_SECRET; + // A non-numeric placeholder is accepted verbatim — no coercion/validation error, + // because the real value was already validated upstream by the proxy daemon. + expect(item.resolvedValue).toBe('vlk_placeholder_NUM_SECRET_abcd1234'); + expect(item.coercionError).toBeUndefined(); + expect(item.validationErrors).toBeUndefined(); + // and the real value is gone + expect(graph.getResolvedEnvObject().NUM_SECRET).not.toBe(42); + }); + + test('omits an item to undefined without tripping the required check', async () => { + const graph = await loadWithView( + outdent` + # --- + # @sensitive @required + REQ_SECRET=real-secret + `, + { REQ_SECRET: { kind: 'omit' } }, + ); + + const item = graph.configSchema.REQ_SECRET; + expect(item.resolvedValue).toBeUndefined(); + expect(item.validationErrors).toBeUndefined(); + }); +}); diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index 92bfb7d03..e7553976f 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { loadEnvGraph, type EnvGraph } from '../env-graph'; +import { loadEnvGraph, type EnvGraph, type ProxyResolutionView } from '../env-graph'; import { VarlockResolver } from './local-encrypt/builtin-resolver'; import { KeychainResolver } from './local-encrypt/keychain-resolver'; import { CliExitError } from '../cli/helpers/exit-error'; @@ -9,7 +9,7 @@ import { runWithWorkspaceInfo } from './workspace-utils'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { createDebug } from './debug'; import { parseOverrideProvenanceMetadata, selectOverrideValuesFromEnv } from './injected-env-provenance'; -import { getProxyPlaceholderOverridesForEnv } from '../proxy/session-registry'; +import { getProxyResolutionViewForEnv } from '../proxy/session-registry'; import { enforceProxySchemaFingerprint } from '../cli/helpers/proxy-schema-fingerprint'; const debug = createDebug('varlock:load'); @@ -52,7 +52,7 @@ function loadFromPaths( overrideValues?: Record, clearCache?: boolean, skipCache?: boolean, - proxyPlaceholderOverrides?: Record, + proxyResolutionView?: ProxyResolutionView, }, ) { const resolvedPaths = rawPaths.map((p) => path.resolve(p)); @@ -83,11 +83,8 @@ function loadFromPaths( afterInit: async (g) => { g.registerResolver(VarlockResolver); g.registerResolver(KeychainResolver); - if (config.proxyPlaceholderOverrides) { - g.overrideValues = { - ...g.overrideValues, - ...config.proxyPlaceholderOverrides, - }; + if (config.proxyResolutionView) { + g.proxyResolutionView = config.proxyResolutionView; } }, }))); @@ -110,9 +107,9 @@ export async function loadVarlockEnvGraph(opts?: { skipProxyFingerprintGuard?: boolean, }) { const runtimeOverrideValues = getGraphEnvOverridesFromRuntimeEnv(); - const proxyPlaceholderOverrides = await getProxyPlaceholderOverridesForEnv().catch(() => undefined); - if (proxyPlaceholderOverrides) { - debug('applying %d proxy placeholder override(s)', Object.keys(proxyPlaceholderOverrides).length); + const proxyResolutionView = await getProxyResolutionViewForEnv().catch(() => undefined); + if (proxyResolutionView) { + debug('applying proxy resolution view (%d item(s))', Object.keys(proxyResolutionView).length); } const cliPaths = opts?.entryFilePaths?.filter(Boolean); @@ -128,7 +125,7 @@ export async function loadVarlockEnvGraph(opts?: { overrideValues: runtimeOverrideValues, clearCache: opts?.clearCache, skipCache: opts?.skipCache, - proxyPlaceholderOverrides, + proxyResolutionView, }); } @@ -145,7 +142,7 @@ export async function loadVarlockEnvGraph(opts?: { overrideValues: runtimeOverrideValues, clearCache: opts?.clearCache, skipCache: opts?.skipCache, - proxyPlaceholderOverrides, + proxyResolutionView, }); } @@ -160,11 +157,8 @@ export async function loadVarlockEnvGraph(opts?: { afterInit: async (g) => { g.registerResolver(VarlockResolver); g.registerResolver(KeychainResolver); - if (proxyPlaceholderOverrides) { - g.overrideValues = { - ...g.overrideValues, - ...proxyPlaceholderOverrides, - }; + if (proxyResolutionView) { + g.proxyResolutionView = proxyResolutionView; } }, }))); diff --git a/packages/varlock/src/lib/test/load-graph.test.ts b/packages/varlock/src/lib/test/load-graph.test.ts index 5b9db0f7a..19e9ba297 100644 --- a/packages/varlock/src/lib/test/load-graph.test.ts +++ b/packages/varlock/src/lib/test/load-graph.test.ts @@ -5,12 +5,16 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -const { getProxyPlaceholderOverridesForEnvMock } = vi.hoisted(() => ({ - getProxyPlaceholderOverridesForEnvMock: vi.fn(), +const { getProxyResolutionViewForEnvMock, getActiveProxySessionMock } = vi.hoisted(() => ({ + getProxyResolutionViewForEnvMock: vi.fn(), + getActiveProxySessionMock: vi.fn(), })); vi.mock('../../proxy/session-registry', () => ({ - getProxyPlaceholderOverridesForEnv: getProxyPlaceholderOverridesForEnvMock, + getProxyResolutionViewForEnv: getProxyResolutionViewForEnvMock, + // The schema-fingerprint guard (run by loadVarlockEnvGraph) resolves the active + // session through this; no session in these tests. + getActiveProxySession: getActiveProxySessionMock, })); import { loadVarlockEnvGraph } from '../load-graph'; @@ -26,24 +30,26 @@ describe('loadVarlockEnvGraph', () => { 'API_KEY=real-secret', '', ].join('\n')); - getProxyPlaceholderOverridesForEnvMock.mockReset(); - getProxyPlaceholderOverridesForEnvMock.mockResolvedValue(undefined); + getProxyResolutionViewForEnvMock.mockReset(); + getProxyResolutionViewForEnvMock.mockResolvedValue(undefined); + getActiveProxySessionMock.mockReset(); + getActiveProxySessionMock.mockResolvedValue(undefined); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - test('uses schema value when no proxy placeholder overrides exist', async () => { + test('uses schema value when no proxy resolution view exists', async () => { const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); await graph.resolveEnvValues(); expect(graph.getResolvedEnvObject().API_KEY).toBe('real-secret'); }); - test('applies proxy placeholder overrides when present', async () => { - getProxyPlaceholderOverridesForEnvMock.mockResolvedValue({ - API_KEY: '<>', + test('applies a proxy placeholder directive when present', async () => { + getProxyResolutionViewForEnvMock.mockResolvedValue({ + API_KEY: { kind: 'placeholder', value: '<>' }, }); const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); @@ -51,4 +57,15 @@ describe('loadVarlockEnvGraph', () => { expect(graph.getResolvedEnvObject().API_KEY).toBe('<>'); }); + + test('resolves an omitted item to undefined without erroring', async () => { + getProxyResolutionViewForEnvMock.mockResolvedValue({ + API_KEY: { kind: 'omit' }, + }); + + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + expect(graph.getResolvedEnvObject().API_KEY).toBeUndefined(); + }); }); diff --git a/packages/varlock/src/proxy/audit.test.ts b/packages/varlock/src/proxy/audit.test.ts index e2574d5ef..1f37469e9 100644 --- a/packages/varlock/src/proxy/audit.test.ts +++ b/packages/varlock/src/proxy/audit.test.ts @@ -152,7 +152,7 @@ describe('proxy audit log', () => { }); const runtime = await startLocalProxyRuntime({ managedItems: [], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', onActivity: (activity) => log.record(activity), }); diff --git a/packages/varlock/src/proxy/placeholder.ts b/packages/varlock/src/proxy/placeholder.ts index 7a890f413..a8a0c9e3d 100644 --- a/packages/varlock/src/proxy/placeholder.ts +++ b/packages/varlock/src/proxy/placeholder.ts @@ -6,29 +6,49 @@ function shortHash(input: string) { return createHash('sha256').update(input).digest('hex').slice(0, 8); } -function fromTypeSettings(item: ConfigItem): string | undefined { +/** + * A unique, format-safe seed for an item's placeholder: lowercase alphanumerics + * and hyphens only, so it embeds cleanly in a hostname / email local-part / etc. + * The key hash keeps distinct items distinct (required so wire scrubbing can't + * confuse two secrets). + */ +function buildPlaceholderSeed(itemKey: string): string { + const slug = itemKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return `vlk-placeholder-${slug}-${shortHash(itemKey)}`; +} + +/** + * Best-effort placeholder honoring a string `@type`'s startsWith/endsWith/isLength + * settings, while embedding the unique seed so distinct items stay distinct. + * Returns undefined when the item has none of those settings. + */ +function fromTypeSettings(item: ConfigItem, seed: string): string | undefined { const typeDecParsedValue = item.getDec('type')?.parsedDecorator.value; if (!typeDecParsedValue || !('simplifiedArgs' in typeDecParsedValue)) return undefined; const simplifiedArgs = typeDecParsedValue.simplifiedArgs; if (!_.isPlainObject(simplifiedArgs)) return undefined; - const startsWith = _.isString(simplifiedArgs.startsWith) - ? simplifiedArgs.startsWith - : ''; - const isLength = _.isNumber(simplifiedArgs.isLength) - ? simplifiedArgs.isLength - : undefined; + const startsWith = _.isString(simplifiedArgs.startsWith) ? simplifiedArgs.startsWith : ''; + const endsWith = _.isString(simplifiedArgs.endsWith) ? simplifiedArgs.endsWith : ''; + const isLength = _.isNumber(simplifiedArgs.isLength) ? simplifiedArgs.isLength : undefined; - if (!startsWith && !isLength) return undefined; + if (!startsWith && !endsWith && isLength === undefined) return undefined; if (isLength !== undefined) { if (isLength <= 0) return ''; - if (startsWith.length >= isLength) return startsWith.slice(0, isLength); - return `${startsWith}${'0'.repeat(isLength - startsWith.length)}`; + const fixedLen = startsWith.length + endsWith.length; + if (fixedLen >= isLength) return `${startsWith}${endsWith}`.slice(0, isLength); + const bodyLen = isLength - fixedLen; + // Fill the bounded middle with hash hex (not the seed prefix, which is constant + // across items) so length-capped placeholders stay unique. 64 hex chars cover + // any realistic length; pad only in the pathological case. + const hex = createHash('sha256').update(seed).digest('hex'); + const body = hex.length >= bodyLen ? hex.slice(0, bodyLen) : `${hex}${'0'.repeat(bodyLen - hex.length)}`; + return `${startsWith}${body}${endsWith}`; } - return `${startsWith}0000000000000000`; + return `${startsWith}${seed}${endsWith}`; } function buildFallbackPlaceholder(itemKey: string): string { @@ -67,14 +87,16 @@ export type GeneratedProxyPlaceholder = { /** * Derive a proxy placeholder for an item, in priority order: * 1. explicit `@placeholder` — the author's exact value - * 2. data-type `generatePlaceholder()` — the blessed source: it encodes the - * provider's real format, so it's the one most likely to pass SDK validation - * 3. `@type` startsWith/isLength constraints — deterministic, but only honors - * the declared validation rules, which may not match the SDK's real checks + * 2. data-type `generatePlaceholder(seed)` — a valid-and-unique form for types + * that have one (url/email/uuid/md5, …); most likely to pass SDK validation + * 3. `@type` startsWith/endsWith/isLength constraints — honors the declared + * string rules while staying unique * 4. generic fallback — guaranteed-unique but format-agnostic (flagged) * - * Deriving from `@example` was intentionally removed: a documentation field - * shouldn't double as a functional, validation-critical placeholder. + * All forms embed a per-item unique seed so distinct secrets never share a + * placeholder (wire scrubbing relies on that). Deriving from `@example` was + * intentionally removed: a documentation field shouldn't double as a functional, + * validation-critical placeholder. */ export async function generateProxyPlaceholderForItem( item: ConfigItem, @@ -88,12 +110,14 @@ export async function generateProxyPlaceholderForItem( } } - const generatedByType = item.dataType?.generatePlaceholder(item.resolvedValue); + const seed = buildPlaceholderSeed(item.key); + + const generatedByType = item.dataType?.generatePlaceholder(seed); if (_.isString(generatedByType) && generatedByType.length > 0) { return { placeholder: ensureUnique(generatedByType, usedPlaceholders), isGenericFallback: false }; } - const fromType = fromTypeSettings(item); + const fromType = fromTypeSettings(item, seed); if (fromType) { return { placeholder: ensureUnique(fromType, usedPlaceholders), isGenericFallback: false }; } diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts index 0f0d047bd..eb439b3f7 100644 --- a/packages/varlock/src/proxy/policy.test.ts +++ b/packages/varlock/src/proxy/policy.test.ts @@ -6,7 +6,7 @@ import { import type { ProxyManagedItem, ProxyRule } from './types'; const rule = (partial: Partial): ProxyRule => ({ - source: 'detached', domain: [], itemKeys: [], ...partial, + domain: [], itemKeys: [], ...partial, }); const facts = (host: string, method: string, path: string): RequestFacts => ({ host, method, path }); diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index 86d8ceba8..e40123f2b 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -122,7 +122,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const activities: Array = []; const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], - rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', onActivity: (a) => activities.push(a), }); @@ -181,7 +181,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], - rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -235,7 +235,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], - rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -275,7 +275,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: REAL }], - rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -311,7 +311,7 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: REAL }], - rules: [{ source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -343,9 +343,9 @@ describe('proxy HTTPS MITM (end-to-end)', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], rules: [ - { source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }, + { domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }, { - source: 'detached', domain: [UPSTREAM_HOST], path: '/v1/charges', method: ['POST'], itemKeys: [], block: true, + domain: [UPSTREAM_HOST], path: '/v1/charges', method: ['POST'], itemKeys: [], block: true, }, ], egressMode: 'permissive', @@ -385,8 +385,8 @@ describe('proxy HTTPS MITM (end-to-end)', () => { { key: 'ITEM_B', placeholder: 'PH_B_xxxxx', realValue: 'REAL_B_secret' }, ], rules: [ - { source: 'attached', domain: [UPSTREAM_HOST], itemKeys: ['ITEM_A'] }, - { source: 'attached', domain: ['other-host.example'], itemKeys: ['ITEM_B'] }, + { domain: [UPSTREAM_HOST], itemKeys: ['ITEM_A'] }, + { domain: ['other-host.example'], itemKeys: ['ITEM_B'] }, ], egressMode: 'permissive', }); diff --git a/packages/varlock/src/proxy/reload-channel.ts b/packages/varlock/src/proxy/reload-channel.ts index 070e38f1a..09d542660 100644 --- a/packages/varlock/src/proxy/reload-channel.ts +++ b/packages/varlock/src/proxy/reload-channel.ts @@ -28,8 +28,8 @@ export type ProxyReloadRequest = { entryPaths?: Array; }; -/** `done`/`error` are terminal. A future approver adds `denied` (+ a pending phase). */ -export type ProxyReloadStatus = 'reloading' | 'done' | 'error'; +/** Both terminal. A future approver adds `denied` (+ a pending phase). */ +export type ProxyReloadStatus = 'done' | 'error'; export type ProxyReloadResult = { requestId: string; diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 168013c17..8a5fb6782 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -3,7 +3,20 @@ import http from 'node:http'; import { URL } from 'node:url'; import type { ProxyActivity } from './audit'; -import { startLocalProxyRuntime } from './runtime-proxy'; +import { replacePlaceholdersWithReal, startLocalProxyRuntime } from './runtime-proxy'; + +describe('replacePlaceholdersWithReal', () => { + test('substitutes the longest placeholder first so substring placeholders are not corrupted', () => { + // P1 is a prefix of P2 — naive left-to-right replacement would splice R1 into + // P2's text and never match P2 correctly. + const managedItems = [ + { key: 'A', placeholder: 'vlk_x', realValue: 'REAL_A' }, + { key: 'B', placeholder: 'vlk_x_1', realValue: 'REAL_B' }, + ]; + const input = 'a=vlk_x&b=vlk_x_1'; + expect(replacePlaceholdersWithReal(input, managedItems as any)).toBe('a=REAL_A&b=REAL_B'); + }); +}); async function requestViaProxy(proxyUrl: string, targetUrl: string, headers?: Record) { const proxy = new URL(proxyUrl); @@ -86,7 +99,7 @@ describe('startLocalProxyRuntime', () => { // Reconfigure to allow 127.0.0.1 → the same request now reaches the upstream. runtime.reconfigure({ managedItems: [], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'strict', }); const allowed = await requestViaProxy(runtime.env.HTTP_PROXY!, target); @@ -120,7 +133,7 @@ describe('startLocalProxyRuntime', () => { const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'PH_placeholder', realValue: 'sk-REAL-secret' }], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], egressMode: 'permissive', }); @@ -159,7 +172,7 @@ describe('startLocalProxyRuntime', () => { // No injected items — these tests exercise forwarding/streaming behavior, // not injection (which now requires TLS, see proxy-tls.test.ts). managedItems: [], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', }); @@ -214,7 +227,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - source: 'attached', domain: ['127.0.0.1'], itemKeys: [], block: true, + domain: ['127.0.0.1'], itemKeys: [], block: true, }, ], egressMode: 'permissive', @@ -247,7 +260,7 @@ describe('startLocalProxyRuntime', () => { const activities: Array = []; const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'PH_placeholder', realValue: 'sk-REAL-secret' }], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: ['API_KEY'] }], egressMode: 'permissive', onActivity: (a) => activities.push(a), }); @@ -277,7 +290,7 @@ describe('startLocalProxyRuntime', () => { const activities: Array = []; const runtime = await startLocalProxyRuntime({ managedItems: [], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', onActivity: (a) => activities.push(a), }); @@ -316,7 +329,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approval: true, + domain: ['127.0.0.1'], itemKeys: [], approval: true, }, ], egressMode: 'permissive', @@ -355,7 +368,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - source: 'attached', domain: ['127.0.0.1'], itemKeys: [], approval: true, + domain: ['127.0.0.1'], itemKeys: [], approval: true, }, ], egressMode: 'permissive', @@ -405,7 +418,7 @@ describe('startLocalProxyRuntime', () => { // No injected items — these tests exercise forwarding/streaming behavior, // not injection (which now requires TLS, see proxy-tls.test.ts). managedItems: [], - rules: [{ source: 'attached', domain: ['127.0.0.1'], itemKeys: [] }], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], egressMode: 'permissive', }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index ba2faa167..3c0d3d7b4 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -144,12 +144,16 @@ async function runApprovalGate(input: { } } -function replacePlaceholdersWithReal(value: string, managedItems: Array): string { +export function replacePlaceholdersWithReal(value: string, managedItems: Array): string { let next = value; - for (const item of managedItems) { - if (item.placeholder) { - next = next.split(item.placeholder).join(item.realValue); - } + // Longest placeholder first, mirroring the scrub direction: if one placeholder + // is a substring of another (e.g. `vlk_x` and `vlk_x_1`), replacing the shorter + // one first would corrupt the longer one and splice in the wrong real value. + const sortedByPlaceholderLength = [...managedItems] + .filter((item) => !!item.placeholder) + .sort((a, b) => b.placeholder.length - a.placeholder.length); + for (const item of sortedByPlaceholderLength) { + next = next.split(item.placeholder).join(item.realValue); } return next; } @@ -179,6 +183,53 @@ function replaceRealWithPlaceholders(value: string, managedItems: Array { - const hostHeader = req.headers.host ?? ''; - const hostInfo = parseHostPort(hostHeader.includes(':') ? hostHeader : `${hostHeader}:443`); - if (!hostInfo) { - res.statusCode = 400; - res.end('Invalid host'); - return; - } - - const method = req.method ?? 'GET'; - const rawUrl = req.url ?? '/'; - const pathOnly = rawUrl.split('?')[0] ?? '/'; + // Shared request pipeline for both transports (MITM tunnel + absolute-form http): + // egress gate → per-call policy (block) → cleartext guard → approval gate → + // scrub+inject → forward upstream (verified identity) → scrub response. Every + // failure path fails closed via respondBlocked. + const processProxiedRequest = async ( + req: http.IncomingMessage, + res: http.ServerResponse, + t: ProxiedRequestTransport, + ) => { const baseActivity = { - host: hostInfo.host, method, path: pathOnly, url: rawUrl, + host: t.host, method: t.method, path: t.pathOnly, url: t.requestTarget, }; - const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); + const shouldRewrite = hostMatchesProxyRules(t.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { onActivity?.({ ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', }); - res.statusCode = 403; - res.end('Proxy egress blocked by strict mode'); + respondBlocked(res, 403, 'Proxy egress blocked by strict mode', false); return; } - // Per-call policy (Invariant: static authorization). Evaluate host + method - // + path against the rules; a matching `block` rule denies the request. - const facts: RequestFacts = { host: hostInfo.host, method, path: pathOnly }; + + // Per-call policy (static authorization): evaluate host + method + path; a + // matching `block` rule denies the request and it never reaches upstream. + const facts: RequestFacts = { host: t.host, method: t.method, path: t.pathOnly }; const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; const ruleIdStr = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; const ruleId = ruleIdStr ? { ruleId: ruleIdStr } : {}; @@ -455,20 +503,26 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', }); - // Fail closed: the request is denied and never reaches upstream. Best-effort - // 403, then tear down (short MITM-tunnel responses don't reliably flush). - try { - res.writeHead(403, { 'content-type': 'text/plain', connection: 'close' }); - res.end('Blocked by proxy policy'); - } catch { /* response may already be gone */ } - res.socket?.destroy(); + respondBlocked(res, 403, 'Blocked by proxy policy', t.tunnelTeardown); return; } const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; + + // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) connection — + // no cert means no verifiable identity. Fail closed. (MITM is always https, so + // this only fires on the absolute-form http path.) + if (hostItems.length > 0 && !t.isHttps) { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'blocked-cleartext', + }); + respondBlocked(res, 403, 'Refusing to inject secrets into a cleartext (non-TLS) connection', false); + return; + } + const body = await readBody(req); const injectedKeys = shouldRewrite - ? detectInjectedKeys([rawUrl, JSON.stringify(req.headers), body.toString('utf8')], hostItems) + ? detectInjectedKeys([t.requestTarget, JSON.stringify(req.headers), body.toString('utf8')], hostItems) : []; // Invariant #8: a require-approval rule holds the request for an out-of-band, @@ -476,9 +530,9 @@ export async function startLocalProxyRuntime({ if (policyDecision?.verdict === 'require-approval') { const approved = await runApprovalGate({ approvalProvider, - method, - host: hostInfo.host, - path: pathOnly, + method: t.method, + host: t.host, + path: t.pathOnly, body, ruleId: ruleIdStr, each: policyDecision.matchedRule?.approvalEach, @@ -489,11 +543,7 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', }); - try { - res.writeHead(403, { 'content-type': 'text/plain', connection: 'close' }); - res.end('Request denied: approval not granted'); - } catch { /* response may already be gone */ } - res.socket?.destroy(); + respondBlocked(res, 403, 'Request denied: approval not granted', t.tunnelTeardown); return; } } @@ -510,6 +560,9 @@ export async function startLocalProxyRuntime({ const rewrittenBody = shouldRewrite ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') : body; + const rewrittenPath = shouldRewrite + ? buildPathnameAndQuery(t.requestTarget, hostItems) + : t.requestTarget; const upstreamHeaders = transformHeaders( req.headers, @@ -519,46 +572,53 @@ export async function startLocalProxyRuntime({ ); delete upstreamHeaders['proxy-connection']; delete upstreamHeaders.connection; - const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(rawUrl, hostItems) - : rawUrl; - + if (t.upstreamHostHeader !== undefined) upstreamHeaders.host = t.upstreamHostHeader; if (rewrittenBody.byteLength !== body.byteLength) { upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); } - const upstreamReq = https.request({ - protocol: 'https:', - hostname: hostInfo.host, - port: hostInfo.port || 443, + const agent = t.isHttps ? https : http; + const upstreamReq = agent.request({ + protocol: t.isHttps ? 'https:' : 'http:', + hostname: t.host, + port: t.port || (t.isHttps ? 443 : 80), method: req.method, path: rewrittenPath, headers: upstreamHeaders, - ...buildVerifiedUpstreamOptions(), + ...(t.isHttps ? buildVerifiedUpstreamOptions() : {}), }, (upstreamRes) => { - forwardUpstreamResponseWithRedaction( - upstreamRes, - res, - hostItems, - shouldRewrite, - ); + forwardUpstreamResponseWithRedaction(upstreamRes, res, hostItems, shouldRewrite); }); upstreamReq.on('error', () => { - // Fail closed: the upstream identity could not be verified (or the - // connection failed), so the secret was never transmitted. Tear the - // client connection down rather than risk a half-delivered response. - if (!res.headersSent) { - try { - res.writeHead(502, { 'content-type': 'text/plain', connection: 'close' }); - res.end('Upstream MITM request failed'); - } catch { /* response may already be gone */ } - } - res.socket?.destroy(); + // Fail closed: the upstream identity could not be verified (or the connection + // failed), so the secret was never transmitted. + respondBlocked(res, 502, 'Upstream request failed', t.tunnelTeardown); }); upstreamReq.end(rewrittenBody); }; + const handleInterceptRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => { + const hostHeader = req.headers.host ?? ''; + const hostInfo = parseHostPort(hostHeader.includes(':') ? hostHeader : `${hostHeader}:443`); + if (!hostInfo) { + res.statusCode = 400; + res.end('Invalid host'); + return; + } + const rawUrl = req.url ?? '/'; + await processProxiedRequest(req, res, { + host: hostInfo.host, + port: hostInfo.port || 443, + isHttps: true, // the MITM tunnel is always TLS + method: req.method ?? 'GET', + pathOnly: rawUrl.split('?')[0] ?? '/', + requestTarget: rawUrl, + upstreamHostHeader: undefined, // pass the client's Host through + tunnelTeardown: true, + }); + }; + const hostMitmServers = new Map(); const getOrCreateHostMitmServer = async (host: string): Promise<{ server: https.Server; port: number }> => { const normalized = normalizeHost(host); @@ -613,131 +673,18 @@ export async function startLocalProxyRuntime({ return; } - const method = clientReq.method ?? 'GET'; - const pathOnly = destination.pathname; - const url = `${destination.pathname}${destination.search}`; - const baseActivity = { - host: destination.hostname, method, path: pathOnly, url, - }; - - const shouldRewrite = hostMatchesProxyRules(destination.hostname, rules); - const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; - if (!shouldAllowEgress) { - onActivity?.({ - ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', - }); - clientRes.statusCode = 403; - clientRes.end('Proxy egress blocked by strict mode'); - return; - } - const facts: RequestFacts = { host: destination.hostname, method, path: pathOnly }; - const policyDecision = shouldRewrite ? evaluateProxyPolicy(facts, rules) : undefined; - const ruleIdStr = policyDecision?.matchedRule ? describeRule(policyDecision.matchedRule) : undefined; - const ruleId = ruleIdStr ? { ruleId: ruleIdStr } : {}; - if (policyDecision?.verdict === 'deny') { - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', - }); - clientRes.statusCode = 403; - clientRes.end('Blocked by proxy policy'); - return; - } - const isHttps = destination.protocol === 'https:'; - const hostItems = shouldRewrite ? getRequestScopedManagedItems(facts, rules, managedItems) : []; - - // Invariant #2/#5: never inject a secret into a cleartext (non-TLS) - // connection — no cert means no verifiable identity. Fail closed. - if (hostItems.length > 0 && !isHttps) { - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'blocked-cleartext', - }); - clientRes.statusCode = 403; - clientRes.end('Refusing to inject secrets into a cleartext (non-TLS) connection'); - return; - } - - const body = await readBody(clientReq); - const injectedKeys = shouldRewrite - ? detectInjectedKeys([url, JSON.stringify(clientReq.headers), body.toString('utf8')], hostItems) - : []; - - // Invariant #8: require-approval holds the request for an out-of-band, - // request-bound decision. Fail closed (deny) unless explicitly approved. - if (policyDecision?.verdict === 'require-approval') { - const approved = await runApprovalGate({ - approvalProvider, - method, - host: destination.hostname, - path: pathOnly, - body, - ruleId: ruleIdStr, - each: policyDecision.matchedRule?.approvalEach, - maxDurationMs: policyDecision.matchedRule?.approvalMaxDurationMs, - injectedKeys, - }); - if (!approved) { - onActivity?.({ - ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', - }); - clientRes.statusCode = 403; - clientRes.end('Request denied: approval not granted'); - return; - } - } - - onActivity?.({ - ...baseActivity, - ...ruleId, - matched: shouldRewrite, - blocked: false, - decision: policyDecision?.verdict === 'require-approval' ? 'approval-granted' : 'allow', - ...(injectedKeys.length ? { injectedKeys } : {}), - }); - - const rewrittenBody = shouldRewrite - ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') - : body; - - const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(`${destination.pathname}${destination.search}`, hostItems) - : `${destination.pathname}${destination.search}`; - - const upstreamHeaders = transformHeaders( - clientReq.headers, - shouldRewrite - ? (value) => replacePlaceholdersWithReal(value, hostItems) - : (value) => value, - ); - delete upstreamHeaders['proxy-connection']; - delete upstreamHeaders.connection; - upstreamHeaders.host = destination.host; - if (rewrittenBody.byteLength !== body.byteLength) { - upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); - } - - const upstream = (isHttps ? https : http).request({ - protocol: destination.protocol, - hostname: destination.hostname, - port: destination.port || (isHttps ? 443 : 80), - method: clientReq.method, - path: rewrittenPath, - headers: upstreamHeaders, - ...(isHttps ? buildVerifiedUpstreamOptions() : {}), - }, (upstreamRes) => { - forwardUpstreamResponseWithRedaction( - upstreamRes, - clientRes, - hostItems, - shouldRewrite, - ); - }); - - upstream.on('error', () => { - if (!clientRes.headersSent) clientRes.statusCode = 502; - clientRes.end('Upstream proxy error'); + const defaultPort = isHttps ? 443 : 80; + await processProxiedRequest(clientReq, clientRes, { + host: destination.hostname, + port: destination.port ? Number(destination.port) : defaultPort, + isHttps, + method: clientReq.method ?? 'GET', + pathOnly: destination.pathname, + requestTarget: `${destination.pathname}${destination.search}`, + upstreamHostHeader: destination.host, // absolute-form: client Host may be the proxy + tunnelTeardown: false, }); - upstream.end(rewrittenBody); }); proxyServer.on('connect', async (req, clientSocket, head) => { diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 7a569fd65..8fe98b419 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -7,6 +7,7 @@ import { join } from 'node:path'; import { getUserVarlockDir } from '../lib/user-config-dir'; import { getAncestorPids } from './process-ancestry'; +import type { ProxyResolutionView } from '../env-graph'; import type { ProxyEgressMode } from './types'; import { PROXY_CHILD_ENV_VAR, @@ -25,11 +26,27 @@ export type ProxySessionRecord = { updatedAt: string; /** Set once the session's process has stopped. A session dir is a durable record; it's marked ended, not deleted. */ endedAt?: string; - /** A long-lived `proxy start` daemon that can hot-reload its policy on `proxy refresh` (via SIGHUP). One-shot `proxy run` sessions are not reloadable. */ + /** A long-lived `proxy start` daemon that can hot-reload its policy on `proxy refresh` (via the file reload channel). One-shot `proxy run` sessions are not reloadable. */ reloadable?: boolean; + /** + * Pids of `proxy run` processes that attached to this (shared daemon) session. + * Ancestry detection matches these so a process under an *attached* run is + * recognized as proxied even if it scrubs the `__VARLOCK_PROXY_*` env markers — + * the daemon's `ownerPid` is not in an attached child's ancestor chain, but the + * attaching `proxy run` process is. Self-owned runs don't need this (their + * `ownerPid` is already an ancestor). Dead pids are harmless and pruned on read. + */ + attachedPids?: Array; egressMode: ProxyEgressMode; schemaFingerprint?: string; + /** + * Sensitive item key → placeholder shown to the child. Covers `@proxy`-managed + * (wire-injected) items and, by default, every other sensitive item. The child + * re-resolves these to the placeholder instead of the real value. + */ placeholderOverrides?: Record; + /** Keys explicitly `@proxy=omit` — absent from the child env and resolved to undefined. */ + omittedKeys?: Array; stats?: ProxySessionStats; env: Record; entryPaths?: Array; @@ -99,6 +116,9 @@ function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | updatedAt: String(parsed.updatedAt), ...(parsed.endedAt ? { endedAt: String(parsed.endedAt) } : {}), ...(parsed.reloadable ? { reloadable: true } : {}), + ...(Array.isArray(parsed.attachedPids) + ? { attachedPids: parsed.attachedPids.map((v) => Number(v)).filter((n) => Number.isFinite(n)) } + : {}), egressMode: parsed.egressMode as ProxyEgressMode, ...(parsed.schemaFingerprint ? { schemaFingerprint: String(parsed.schemaFingerprint) } : {}), ...(parsed.placeholderOverrides && typeof parsed.placeholderOverrides === 'object' @@ -108,6 +128,9 @@ function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | ), } : {}), + ...(Array.isArray(parsed.omittedKeys) + ? { omittedKeys: parsed.omittedKeys.map((v) => String(v)) } + : {}), ...(parsed.stats && typeof parsed.stats === 'object' ? { stats: { @@ -267,14 +290,72 @@ export async function resolveActiveProxySession( const ancestors = new Set(getAncestorPids()); return sessions.find( - (s) => ancestors.has(s.ownerPid) || (s.childPid !== undefined && ancestors.has(s.childPid)), + (s) => ancestors.has(s.ownerPid) + || (s.childPid !== undefined && ancestors.has(s.childPid)) + || (s.attachedPids?.some((pid) => ancestors.has(pid)) ?? false), ); } -export async function getProxyPlaceholderOverridesForEnv(env: NodeJS.ProcessEnv = process.env) { - const session = await resolveActiveProxySession(env); - if (!session?.placeholderOverrides) return undefined; - return session.placeholderOverrides; +/** + * Memoized authoritative "what proxy session is this process running under?". + * Proxy-child status is invariant for a process's lifetime (its place in the + * process tree and its injected env don't change), so we resolve it at most once + * per process for the real `process.env` path — the ancestry walk can spawn `ps` + * on non-Linux, so repeating it on every guard/load would be wasteful. Explicit + * `env` args (tests) bypass the cache so each scenario resolves fresh. + * + * This is the single seam every consumer (context guard, fingerprint guard, + * load-graph placeholder/omit view) should use, so detection lives in one place. + */ +let memoizedDefaultSession: Promise | undefined; + +export async function getActiveProxySession( + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (env !== process.env) return resolveActiveProxySession(env); + memoizedDefaultSession ??= resolveActiveProxySession(env).catch((err) => { + // Don't cache a rejection — allow a later call to retry. + memoizedDefaultSession = undefined; + throw err; + }); + return memoizedDefaultSession; +} + +/** Test-only: clear the per-process memoized detection result. */ +export function resetActiveProxySessionCache() { + memoizedDefaultSession = undefined; +} + +/** + * Whether this process is running inside a proxy session. The injected env marker + * is the fast path; the session token and process ancestry (via + * `getActiveProxySession`) are the authoritative fallbacks a child can't shed by + * clearing `__VARLOCK_PROXY_CHILD`. + */ +export async function isProxyChildContext(env: NodeJS.ProcessEnv = process.env): Promise { + if (env[PROXY_CHILD_ENV_VAR] === '1') return true; + return !!(await getActiveProxySession(env)); +} + +/** + * The proxy-child resolution view for the active session: each placeholder key → + * a placeholder directive, each omitted key → an omit directive. Consumed by + * `load-graph` to force sensitive items to placeholders (or unset) during a + * proxied re-resolution. Returns undefined outside a proxy context. + */ +export async function getProxyResolutionViewForEnv( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const session = await getActiveProxySession(env); + if (!session) return undefined; + const view: ProxyResolutionView = {}; + for (const [key, value] of Object.entries(session.placeholderOverrides ?? {})) { + view[key] = { kind: 'placeholder', value }; + } + for (const key of session.omittedKeys ?? []) { + view[key] = { kind: 'omit' }; + } + return Object.keys(view).length ? view : undefined; } export async function updateProxySessionRecord( @@ -292,6 +373,27 @@ export async function updateProxySessionRecord( return next; } +/** + * Register an attaching `proxy run` process pid on a (shared daemon) session so + * ancestry detection recognizes processes under that run. Read-modify-write on + * the shared record; concurrent attaches race but that's tolerable for the local + * single-user MVP. Dead pids are pruned here. + */ +export async function addProxySessionAttachment(uuid: string, pid: number) { + const existing = await getProxySessionByToken(uuid); + if (!existing) return; + const live = (existing.attachedPids ?? []).filter((p) => isProcessRunning(p)); + const next = Array.from(new Set([...live, pid])); + await updateProxySessionRecord(uuid, { attachedPids: next }); +} + +export async function removeProxySessionAttachment(uuid: string, pid: number) { + const existing = await getProxySessionByToken(uuid); + if (!existing?.attachedPids) return; + const next = existing.attachedPids.filter((p) => p !== pid && isProcessRunning(p)); + await updateProxySessionRecord(uuid, { attachedPids: next }); +} + export async function resolveProxySessionForCommand(opts?: { explicitSession?: string; env?: NodeJS.ProcessEnv; diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index a4a14ab49..89e0f073b 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -1,7 +1,5 @@ export type ProxyEgressMode = 'permissive' | 'strict'; -export type ProxyRuleSource = 'attached' | 'detached'; - /** * Approval granularity — what a single approval (and any standing grant) covers. * `host` = the host; `endpoint` = method + path; `request` = method + path + body. @@ -11,7 +9,6 @@ export type ProxyApprovalEach = 'host' | 'endpoint' | 'request'; export const PROXY_APPROVAL_EACH_VALUES: ReadonlyArray = ['host', 'endpoint', 'request']; export type ProxyRule = { - source: ProxyRuleSource; domain: Array; itemKeys: Array; path?: string; From 930c714db562cd7fe8a603229b74f5a581c66655 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 17 Jun 2026 23:41:18 -0700 Subject: [PATCH 26/64] refactor(proxy): group approval options into an approval={...} object @proxy approval config moves from flat approvalEach/approvalMaxDuration options to a nested object on `approval`: @proxy(domain=..., approval={each=request, maxDuration=15m}) `approval=true` still works as shorthand; the object form implies approval is required unless `enabled=false`. Mirrors @sensitive's object form and uses the multi-line object-literal parser support. Internal ProxyRule runtime fields are unchanged. Unknown approval options are rejected. --- .bumpy/proxy-approval-granularity.md | 2 +- .../src/content/docs/guides/proxy.mdx | 13 +++- .../docs/reference/item-decorators.mdx | 6 +- .../varlock/src/env-graph/lib/decorators.ts | 74 ++++++++++++------- .../varlock/src/env-graph/lib/env-graph.ts | 67 +++++++++++------ .../src/env-graph/test/proxy-mode.test.ts | 36 +++++++-- 6 files changed, 137 insertions(+), 61 deletions(-) diff --git a/.bumpy/proxy-approval-granularity.md b/.bumpy/proxy-approval-granularity.md index ab5de487e..49616a75c 100644 --- a/.bumpy/proxy-approval-granularity.md +++ b/.bumpy/proxy-approval-granularity.md @@ -4,4 +4,4 @@ varlock: patch Proxy approvals: per-rule granularity and a max-duration cap. -`@proxy(approval=true)` rules now accept `approvalEach` — how finely to ask: `host`, `endpoint` (method+path), or `request` (method+path+body) — and `approvalMaxDuration`, the ceiling on how long a "yes" is remembered (e.g. `"15m"`, or `0` for always-ask). A standing grant is keyed by the matched rule plus its granularity, so one broad rule can yield per-endpoint or per-exact-request approvals without writing many rules. The cap is enforced proxy-side: the approver's chosen lifetime is clamped to `approvalMaxDuration`, so no approver can exceed what the schema allows (always-ask is provably one-tap-per-request). The `@proxy(approve=…)` option is renamed to `approval`. +`@proxy(approval=true)` rules accept an options object for finer control: `@proxy(approval={each="request", maxDuration="15m"})`. `each` is how finely to ask — `host`, `endpoint` (method+path), or `request` (method+path+body) — and `maxDuration` is the ceiling on how long a "yes" is remembered (e.g. `"15m"`, or `0` for always-ask). The object form implies approval is required; `enabled=false` turns the rule back into a plain allow. A standing grant is keyed by the matched rule plus its granularity, so one broad rule can yield per-endpoint or per-exact-request approvals without writing many rules. The cap is enforced proxy-side: the approver's chosen lifetime is clamped to `maxDuration`, so no approver can exceed what the schema allows (always-ask is provably one-tap-per-request). diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 12454755d..3df56005b 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -242,10 +242,17 @@ A rule can require explicit, out-of-band approval before each matching request i STRIPE_SECRET_KEY=sk_live_... ``` -When matched under `proxy start`, the request is held and you're prompted to approve it (once, for the session, or for a time window). Two knobs tune this: +When matched under `proxy start`, the request is held and you're prompted to approve it (once, for the session, or for a time window). For finer control, pass `approval` as an options object instead of `approval=true`: -- `approvalEach` — what one approval covers: `host`, `endpoint` (method + path, the default), or `request` (also binds the body). -- `approvalMaxDuration` — a ceiling on how long a "yes" is remembered: a duration like `"15m"`, `0` for always-ask, or unset for the whole session. The ceiling is enforced by the proxy, not just the prompt. +```env-spec title=".env.schema" +# @proxy(domain="api.stripe.com", path="/v1/refunds/**", approval={each=request, maxDuration="15m"}) +``` + +- `each` — what one approval covers: `host`, `endpoint` (method + path, the default), or `request` (also binds the body). +- `maxDuration` — a ceiling on how long a "yes" is remembered: a duration like `"15m"`, `0` for always-ask, or unset for the whole session. The ceiling is enforced by the proxy, not just the prompt. +- `enabled` — set `enabled=false` to turn the rule into a plain allow (useful for dynamically toggling approval, e.g. `enabled=forEnv(production)`). + +The object form implies approval is required; `approval=true` is shorthand for the defaults. ## Reference diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 0d9d79135..4c018202f 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,7 +256,7 @@ OPENAI_API_KEY=sk-proj-... 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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). -**Function form** — `@proxy(domain=..., [path], [method], [block], [keys], [approval], [approvalEach], [approvalMaxDuration])`: +**Function form** — `@proxy(domain=..., [path], [method], [block], [keys], [approval])`: | Option | Meaning | |---|---| @@ -265,9 +265,7 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | | `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. | -| `approval` _(experimental)_ | `approval=true` holds matching requests for interactive approval. | -| `approvalEach` _(experimental)_ | What one approval covers: `host`|`endpoint`|`request`. | -| `approvalMaxDuration` _(experimental)_ | Ceiling on how long a "yes" is remembered (`"15m"`, `0` = always ask). | +| `approval` _(experimental)_ | `approval=true` holds matching requests for interactive approval. Pass an options object for finer control: `approval={each=request, maxDuration="15m"}` — `each` (`host`|`endpoint`|`request`) is what one approval covers, `maxDuration` caps how long a "yes" is remembered (`"15m"`, `0` = always ask), and `enabled=false` makes it a plain allow. The object form implies approval is required. | The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`). diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index dd08a824d..59e3e3144 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -8,7 +8,7 @@ import { import { EnvGraphDataSource } from './data-source'; import type { ConfigItem } from './config-item'; import { - StaticValueResolver, ArrayLiteralResolver, type Resolver, convertParsedValueToResolvers, + StaticValueResolver, ArrayLiteralResolver, ObjectLiteralResolver, type Resolver, convertParsedValueToResolvers, } from './resolver'; import { ResolutionError, SchemaError, type VarlockError } from './errors'; import type { EnvGraph } from './env-graph'; @@ -341,12 +341,14 @@ 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', 'approvalEach', 'approvalMaxDuration'] as const; - -/** A static boolean option (`block`/`approval`) must be a real boolean — a quoted - * `"true"` or `1` is a misconfiguration that would otherwise silently drop the - * option (turning a deny/approval rule into a plain allow). Dynamic expressions - * are validated at resolve time. */ +const VALID_PROXY_OPTIONS = ['domain', 'path', 'method', 'keys', 'block', 'approval'] as const; +/** Inner options of the `approval={...}` object form. */ +const VALID_APPROVAL_OPTIONS = ['enabled', 'each', 'maxDuration'] as const; + +/** A static boolean option (`block`) must be a real boolean — a quoted `"true"` + * or `1` is a misconfiguration that would otherwise silently drop the option + * (turning a deny rule into a plain allow). Dynamic expressions are validated at + * resolve time. */ function assertProxyBooleanArg(resolver: Resolver | undefined, option: string): void { if (!resolver?.isStatic) return; if (typeof resolver.staticValue !== 'boolean') { @@ -362,6 +364,44 @@ function assertProxyStringArg(resolver: Resolver | undefined, option: string): v } } +/** + * `approval` accepts either a boolean (`approval=true`) or an options object + * (`approval={each=request, maxDuration=15m}`); the object form implies approval + * is required unless `enabled=false`. Validates the inner options statically when + * they're literals; dynamic values are re-checked at resolve time. + */ +function assertProxyApprovalArg(resolver: Resolver | undefined): void { + if (!resolver) return; + if (resolver instanceof ObjectLiteralResolver) { + const inner = resolver.objArgs ?? {}; + for (const key of Object.keys(inner)) { + if (!VALID_APPROVAL_OPTIONS.includes(key as typeof VALID_APPROVAL_OPTIONS[number])) { + throw new SchemaError(`@proxy: unknown approval option "${key}". Valid options: ${VALID_APPROVAL_OPTIONS.join(', ')}`); + } + } + const each = inner.each; + if (each?.isStatic && !(typeof each.staticValue === 'string' && PROXY_APPROVAL_EACH_VALUES.includes(each.staticValue as any))) { + throw new SchemaError(`@proxy: approval.each must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); + } + const maxDuration = inner.maxDuration; + if (maxDuration?.isStatic) { + try { + parseDuration(maxDuration.staticValue as string | number); + } catch { + throw new SchemaError('@proxy: approval.maxDuration must be a duration like "15m" or 0 (always ask)'); + } + } + const enabled = inner.enabled; + if (enabled?.isStatic && typeof enabled.staticValue !== 'boolean') { + throw new SchemaError('@proxy: approval.enabled must be a boolean (true or false)'); + } + return; + } + if (resolver.isStatic && typeof resolver.staticValue !== 'boolean') { + throw new SchemaError('@proxy: approval must be true/false or an options object, e.g. approval={each=request, maxDuration=15m}'); + } +} + function validateProxyFunctionArgs(argsVal: Resolver): void { if (!argsVal.objArgs?.domain) { throw new SchemaError('@proxy: missing required "domain" option'); @@ -382,27 +422,11 @@ function validateProxyFunctionArgs(argsVal: Resolver): void { assertProxyStringListArg(argsVal.objArgs?.keys, 'keys', true); assertProxyStringArg(argsVal.objArgs?.path, 'path'); assertProxyBooleanArg(argsVal.objArgs?.block, 'block'); - assertProxyBooleanArg(argsVal.objArgs?.approval, 'approval'); + assertProxyApprovalArg(argsVal.objArgs?.approval); if (argsVal.arrArgs?.length) { throw new SchemaError('@proxy: positional args are not supported - use keys=[ITEM_A, ITEM_B] to attach items'); } - - const eachResolver = argsVal.objArgs?.approvalEach; - if (eachResolver?.isStatic) { - const eachVal = eachResolver.staticValue; - if (typeof eachVal !== 'string' || !PROXY_APPROVAL_EACH_VALUES.includes(eachVal as any)) { - throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); - } - } - const maxDurResolver = argsVal.objArgs?.approvalMaxDuration; - if (maxDurResolver?.isStatic) { - try { - parseDuration(maxDurResolver.staticValue as string | number); - } catch { - throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); - } - } } // root decorators @@ -729,7 +753,7 @@ export const builtInItemDecorators: Array> = [ } return; } - // Function form: @proxy(domain=..., [path], [method], [block], [approval], [approvalEach], [approvalMaxDuration], [keys=[...]]) + // Function form: @proxy(domain=..., [path], [method], [block], [approval=true | approval={each, maxDuration, enabled}], [keys=[...]]) validateProxyFunctionArgs(decVal); }, }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index c667126a4..9d201ba52 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -922,41 +922,64 @@ export class EnvGraph { if (obj?.block !== undefined && !_.isBoolean(obj.block)) { throw new SchemaError(`@proxy: block must resolve to a boolean, got ${JSON.stringify(obj.block)}`); } - if (obj?.approval !== undefined && !_.isBoolean(obj.approval)) { - throw new SchemaError(`@proxy: approval must resolve to a boolean, got ${JSON.stringify(obj.approval)}`); - } if (obj?.path !== undefined && !_.isString(obj.path)) { throw new SchemaError(`@proxy: path must resolve to a string, got ${JSON.stringify(obj.path)}`); } - const eachOk = _.isString(obj?.approvalEach) - && PROXY_APPROVAL_EACH_VALUES.includes(obj.approvalEach as ProxyApprovalEach); - if (obj?.approvalEach !== undefined && !eachOk) { - throw new SchemaError(`@proxy: approvalEach must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); - } - if (obj?.approvalMaxDuration !== undefined) { - try { - parseDuration(obj.approvalMaxDuration); - } catch { - throw new SchemaError('@proxy: approvalMaxDuration must be a duration like "15m" or 0 (always ask)'); + // `approval` resolves to either a boolean or an options object `{enabled?, each?, maxDuration?}`. + const approval = obj?.approval; + if (approval !== undefined && !_.isBoolean(approval)) { + if (!_.isPlainObject(approval)) { + throw new SchemaError(`@proxy: approval must resolve to a boolean or an options object, got ${JSON.stringify(approval)}`); + } + for (const key of Object.keys(approval)) { + if (!['enabled', 'each', 'maxDuration'].includes(key)) { + throw new SchemaError(`@proxy: unknown approval option "${key}". Valid options: enabled, each, maxDuration`); + } + } + if (approval.enabled !== undefined && !_.isBoolean(approval.enabled)) { + throw new SchemaError(`@proxy: approval.enabled must resolve to a boolean, got ${JSON.stringify(approval.enabled)}`); + } + const eachOk = _.isString(approval.each) + && PROXY_APPROVAL_EACH_VALUES.includes(approval.each as ProxyApprovalEach); + if (approval.each !== undefined && !eachOk) { + throw new SchemaError(`@proxy: approval.each must be one of ${PROXY_APPROVAL_EACH_VALUES.join(', ')}`); + } + if (approval.maxDuration !== undefined) { + try { + parseDuration(approval.maxDuration as string | number); + } catch { + throw new SchemaError('@proxy: approval.maxDuration must be a duration like "15m" or 0 (always ask)'); + } } } } /** * Approval fields for a rule, from a resolved `@proxy(...)` arg object. - * Approval is required if `approval=true` or any approval config prop is set. - * Assumes the object has already passed `validateResolvedProxyObj`. + * `approval` is a boolean (`approval=true`) or an options object + * (`approval={each=..., maxDuration=...}`); the object form implies required + * unless `enabled=false`. Assumes the object passed `validateResolvedProxyObj`. */ private static buildProxyApprovalFields(obj: any): Partial { - const approvalEach = _.isString(obj?.approvalEach) ? (obj.approvalEach as ProxyApprovalEach) : undefined; - const approvalMaxDurationMs = obj?.approvalMaxDuration !== undefined - ? parseDuration(obj.approvalMaxDuration) - : undefined; - const required = obj?.approval === true || approvalEach !== undefined || approvalMaxDurationMs !== undefined; + const approval = obj?.approval; + let required = false; + let approvalEach: ProxyApprovalEach | undefined; + let approvalMaxDurationMs: number | undefined; + + if (_.isBoolean(approval)) { + required = approval; + } else if (_.isPlainObject(approval)) { + required = approval.enabled !== false; // object form implies required unless explicitly disabled + approvalEach = _.isString(approval.each) ? (approval.each as ProxyApprovalEach) : undefined; + approvalMaxDurationMs = approval.maxDuration !== undefined + ? parseDuration(approval.maxDuration as string | number) + : undefined; + } + return { ...(required ? { approval: true } : {}), - ...(approvalEach ? { approvalEach } : {}), - ...(approvalMaxDurationMs !== undefined ? { approvalMaxDurationMs } : {}), + ...(required && approvalEach ? { approvalEach } : {}), + ...(required && approvalMaxDurationMs !== undefined ? { approvalMaxDurationMs } : {}), }; } diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 8da663767..78fa94ee1 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -165,12 +165,12 @@ describe('proxy decorators', () => { ]); }); - test('approval config: approvalEach + approvalMaxDuration parse onto the rule', async () => { + test('approval object form: each + maxDuration parse onto the rule', async () => { const graph = await loadGraph(outdent` # @enableProxy(egress="strict") # @proxy(domain="api.a.com", approval=true) - # @proxy(domain="api.b.com", approvalEach="request", approvalMaxDuration="15m") - # @proxy(domain="api.c.com", approvalEach="host", approvalMaxDuration=0) + # @proxy(domain="api.b.com", approval={each=request, maxDuration="15m"}) + # @proxy(domain="api.c.com", approval={each=host, maxDuration=0}) # --- BASELINE=1 `); @@ -186,15 +186,39 @@ describe('proxy decorators', () => { ]); }); - test('approval config: a bad approvalEach is rejected', async () => { + test('approval object form: enabled=false makes the rule a plain allow (no approval)', async () => { const graph = await loadGraph(outdent` # @defaultSensitive=false # --- - # @proxy(domain="api.a.com", approvalEach="bogus") + # @proxy(domain="api.a.com", approval={enabled=false, each=request}) + API_KEY=secret + `); + const rules = await graph.getProxyRules(); + expect(rules).toMatchObject([{ domain: ['api.a.com'] }]); + expect(rules[0]!.approval).toBeUndefined(); + expect(rules[0]!.approvalEach).toBeUndefined(); + }); + + test('approval config: a bad approval.each is rejected', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", approval={each=bogus}) + API_KEY=secret + `); + const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; + expect(errors.some((e) => /approval\.each must be one of/.test(e.message))).toBe(true); + }); + + test('approval config: an unknown approval option is rejected', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy(domain="api.a.com", approval={eech=request}) API_KEY=secret `); const errors = graph.configSchema.API_KEY.decoratorSchemaErrors; - expect(errors.some((e) => /approvalEach must be one of/.test(e.message))).toBe(true); + expect(errors.some((e) => /unknown approval option "eech"/.test(e.message))).toBe(true); }); test('@proxy=passthrough / =omit parse as value-form modes (no rule created)', async () => { From 6ddcec39b200ef8616c8b93cbea0afbead71dafc Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 19 Jun 2026 13:37:23 -0700 Subject: [PATCH 27/64] refactor(proxy): nest approval granularity in the runtime ProxyRule type Replace the flat ProxyRule.approval/approvalEach/approvalMaxDurationMs fields with a nested `approval?: { each?, maxDurationMs? }` where presence implies required. Makes 'granularity without approval' unrepresentable and simplifies buildProxyApprovalFields. policy.ts truthy checks are unchanged (object is truthy); only the gate wiring reads .approval.each/.maxDurationMs. Authoring surface and runtime semantics are identical. --- .../varlock/src/env-graph/lib/env-graph.ts | 16 +++++++------- .../src/env-graph/test/proxy-mode.test.ts | 9 ++++---- packages/varlock/src/proxy/policy.test.ts | 8 +++---- .../varlock/src/proxy/runtime-proxy.test.ts | 4 ++-- packages/varlock/src/proxy/runtime-proxy.ts | 4 ++-- packages/varlock/src/proxy/types.ts | 21 ++++++++++++------- 6 files changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 9d201ba52..98c78bac4 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -963,23 +963,25 @@ export class EnvGraph { private static buildProxyApprovalFields(obj: any): Partial { const approval = obj?.approval; let required = false; - let approvalEach: ProxyApprovalEach | undefined; - let approvalMaxDurationMs: number | undefined; + let each: ProxyApprovalEach | undefined; + let maxDurationMs: number | undefined; if (_.isBoolean(approval)) { required = approval; } else if (_.isPlainObject(approval)) { required = approval.enabled !== false; // object form implies required unless explicitly disabled - approvalEach = _.isString(approval.each) ? (approval.each as ProxyApprovalEach) : undefined; - approvalMaxDurationMs = approval.maxDuration !== undefined + each = _.isString(approval.each) ? (approval.each as ProxyApprovalEach) : undefined; + maxDurationMs = approval.maxDuration !== undefined ? parseDuration(approval.maxDuration as string | number) : undefined; } + if (!required) return {}; return { - ...(required ? { approval: true } : {}), - ...(required && approvalEach ? { approvalEach } : {}), - ...(required && approvalMaxDurationMs !== undefined ? { approvalMaxDurationMs } : {}), + approval: { + ...(each ? { each } : {}), + ...(maxDurationMs !== undefined ? { maxDurationMs } : {}), + }, }; } diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 78fa94ee1..75c0e96ad 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -161,7 +161,7 @@ describe('proxy decorators', () => { const rules = await graph.getProxyRules(); expect(rules).toMatchObject([ { domain: ['api.a.com'] }, - { domain: ['api.b.com'], path: '/admin/**', approval: true }, + { domain: ['api.b.com'], path: '/admin/**', approval: {} }, ]); }); @@ -176,12 +176,12 @@ describe('proxy decorators', () => { `); expect(await graph.getProxyRules()).toMatchObject([ - { domain: ['api.a.com'], approval: true }, + { domain: ['api.a.com'], approval: {} }, { - domain: ['api.b.com'], approval: true, approvalEach: 'request', approvalMaxDurationMs: 900_000, + domain: ['api.b.com'], approval: { each: 'request', maxDurationMs: 900_000 }, }, { - domain: ['api.c.com'], approval: true, approvalEach: 'host', approvalMaxDurationMs: 0, + domain: ['api.c.com'], approval: { each: 'host', maxDurationMs: 0 }, }, ]); }); @@ -196,7 +196,6 @@ describe('proxy decorators', () => { const rules = await graph.getProxyRules(); expect(rules).toMatchObject([{ domain: ['api.a.com'] }]); expect(rules[0]!.approval).toBeUndefined(); - expect(rules[0]!.approvalEach).toBeUndefined(); }); test('approval config: a bad approval.each is rejected', async () => { diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts index eb439b3f7..a0f455b8e 100644 --- a/packages/varlock/src/proxy/policy.test.ts +++ b/packages/varlock/src/proxy/policy.test.ts @@ -51,7 +51,7 @@ describe('evaluateProxyPolicy', () => { }); test('an approve rule yields require-approval', () => { - const approveRules = [rule({ domain: ['api.x.com'], path: '/v1/refunds/**', approval: true })]; + const approveRules = [rule({ domain: ['api.x.com'], path: '/v1/refunds/**', approval: {} })]; expect(evaluateProxyPolicy(facts('api.x.com', 'POST', '/v1/refunds/42'), approveRules).verdict).toBe('require-approval'); // a non-matching path falls through to allow expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/customers'), approveRules).verdict).toBe('allow'); @@ -60,7 +60,7 @@ describe('evaluateProxyPolicy', () => { test('block beats require-approval beats allow', () => { const layered = [ rule({ domain: ['api.x.com'] }), // broad allow - rule({ domain: ['api.x.com'], path: '/admin/**', approval: true }), + rule({ domain: ['api.x.com'], path: '/admin/**', approval: {} }), rule({ domain: ['api.x.com'], path: '/admin/destroy', block: true }), ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/admin/destroy'), layered).verdict).toBe('deny'); @@ -70,7 +70,7 @@ describe('evaluateProxyPolicy', () => { test('a more-specific allow exempts a path from a broad approve', () => { const broadApprove = [ - rule({ domain: ['api.x.com'], approval: true }), // approve everything by default + rule({ domain: ['api.x.com'], approval: {} }), // approve everything by default rule({ domain: ['api.x.com'], path: '/health' }), // ...except this safe path ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/anything'), broadApprove).verdict).toBe('require-approval'); @@ -80,7 +80,7 @@ describe('evaluateProxyPolicy', () => { test('on a specificity tie, require-approval wins over allow (more restrictive)', () => { const tied = [ rule({ domain: ['api.x.com'], path: '/v1/*' }), - rule({ domain: ['api.x.com'], path: '/v1/*', approval: true }), + rule({ domain: ['api.x.com'], path: '/v1/*', approval: {} }), ]; expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/x'), tied).verdict).toBe('require-approval'); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 8a5fb6782..7cbfaaa23 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -329,7 +329,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - domain: ['127.0.0.1'], itemKeys: [], approval: true, + domain: ['127.0.0.1'], itemKeys: [], approval: {}, }, ], egressMode: 'permissive', @@ -368,7 +368,7 @@ describe('startLocalProxyRuntime', () => { managedItems: [], rules: [ { - domain: ['127.0.0.1'], itemKeys: [], approval: true, + domain: ['127.0.0.1'], itemKeys: [], approval: {}, }, ], egressMode: 'permissive', diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 3c0d3d7b4..2d1410590 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -535,8 +535,8 @@ export async function startLocalProxyRuntime({ path: t.pathOnly, body, ruleId: ruleIdStr, - each: policyDecision.matchedRule?.approvalEach, - maxDurationMs: policyDecision.matchedRule?.approvalMaxDurationMs, + each: policyDecision.matchedRule?.approval?.each, + maxDurationMs: policyDecision.matchedRule?.approval?.maxDurationMs, injectedKeys, }); if (!approved) { diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts index 89e0f073b..d5ed0ae1e 100644 --- a/packages/varlock/src/proxy/types.ts +++ b/packages/varlock/src/proxy/types.ts @@ -15,16 +15,21 @@ export type ProxyRule = { /** Allowed HTTP methods (uppercased). Omitted = any method. */ method?: Array; block?: boolean; - /** Require out-of-band approval before this request is forwarded (Invariant #8). */ - approval?: boolean; - /** Granularity of approvals / standing grants. Default `endpoint`. */ - approvalEach?: ProxyApprovalEach; /** - * Ceiling on how long a "yes" may be remembered, in ms — the schema-enforced - * cap on grant lifetime. `0` = always ask (never remembered); `undefined` = - * may persist for the whole session. + * Require out-of-band approval before this request is forwarded (Invariant #8). + * Presence ⇒ required; `undefined` ⇒ no approval. Nesting the granularity here + * makes "granularity without approval" unrepresentable. */ - approvalMaxDurationMs?: number; + approval?: { + /** Granularity of approvals / standing grants. Default `endpoint`. */ + each?: ProxyApprovalEach; + /** + * Ceiling on how long a "yes" may be remembered, in ms — the schema-enforced + * cap on grant lifetime. `0` = always ask (never remembered); `undefined` = + * may persist for the whole session. + */ + maxDurationMs?: number; + }; }; export type ProxyManagedItem = { From 91b86f4bb214415538c3acab2aea45806f97b8ad Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 19 Jun 2026 15:29:58 -0700 Subject: [PATCH 28/64] feat(proxy): live request/response log in `proxy start` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon terminal now tails a one-line-per-request log: the request decision with injected keys (→ ... inject: KEY), and the forwarded response status with any keys scrubbed back to placeholders (← ... scrubbed: KEY). Adds an onResponse callback to the runtime that surfaces response-side scrubbing (headers + buffered body fully; streamed bodies accumulate matched keys; compressed/binary report header reflections only). --- .bumpy/proxy-start-request-log.md | 15 ++++ .../varlock/src/cli/commands/proxy.command.ts | 27 ++++++- packages/varlock/src/proxy/proxy-tls.test.ts | 5 ++ packages/varlock/src/proxy/runtime-proxy.ts | 71 +++++++++++++++++-- 4 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 .bumpy/proxy-start-request-log.md diff --git a/.bumpy/proxy-start-request-log.md b/.bumpy/proxy-start-request-log.md new file mode 100644 index 000000000..22541e833 --- /dev/null +++ b/.bumpy/proxy-start-request-log.md @@ -0,0 +1,15 @@ +--- +varlock: patch +--- + +`varlock proxy start` now prints a live request log to its terminal. + +Each proxied request shows a one-line decision with the keys injected on the way +out, and each forwarded response shows its status and any keys scrubbed back to +placeholders on the way in: + +``` +→ GET httpbin.org/get inject: API_TOKEN +← GET httpbin.org/get 200 scrubbed: API_TOKEN +✗ GET example.com/ blocked-egress +``` diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index a4ebb435d..cf067442f 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -5,10 +5,11 @@ import { gracefulExit } from 'exit-hook'; import { exec } from '../../lib/exec'; import { loadVarlockEnvGraph } from '../../lib/load-graph'; -import { startLocalProxyRuntime } from '../../proxy/runtime-proxy'; +import { startLocalProxyRuntime, type ProxyResponseInfo } from '../../proxy/runtime-proxy'; import { createProxyAuditLog, readProxyAuditLines, + type ProxyActivity, type ProxyAuditEntry, type ProxyAuditLog, } from '../../proxy/audit'; @@ -357,6 +358,21 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise; @@ -366,6 +382,8 @@ async function createRuntimeAndSession(opts: { enableApprovalGrants?: boolean; /** Mark the session as a hot-reloadable daemon (so `proxy refresh` can reload it). */ reloadable?: boolean; + /** Print a live per-request/response log to stderr (for the `proxy start` terminal). */ + logRequests?: boolean; }): Promise<{ runtime: Awaited>; session: ProxySessionRecord; @@ -401,7 +419,11 @@ async function createRuntimeAndSession(opts: { onActivity: (activity) => { statsWriter.onActivity(activity); auditLog.record(activity); + if (opts.logRequests) console.error(formatProxyRequestLog(activity)); }, + onResponse: opts.logRequests + ? (info) => console.error(formatProxyResponseLog(info)) + : undefined, }); const session = await createProxySessionRecord({ id: identity.id, @@ -824,12 +846,15 @@ async function startAction(ctx: any) { approvalProvider: createTtyApprovalProvider(), enableApprovalGrants: true, reloadable: true, + // The daemon owns this terminal, so tail a live per-request/response log here. + logRequests: true, }); console.log(`Started proxy session ${session.id} (${session.uuid})`); console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); + console.log('Live request log (→ request · ← response):'); // Service `proxy refresh` requests: hot-reload the live policy without dropping // the proxy. The daemon owns this terminal, so reload notices print here. diff --git a/packages/varlock/src/proxy/proxy-tls.test.ts b/packages/varlock/src/proxy/proxy-tls.test.ts index e40123f2b..cda0b4801 100644 --- a/packages/varlock/src/proxy/proxy-tls.test.ts +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -273,10 +273,12 @@ describe('proxy HTTPS MITM (end-to-end)', () => { res.end(JSON.stringify({ apiKey: REAL })); }); + const responses: Array<{ statusCode: number; scrubbedKeys: Array }> = []; const runtime = await startLocalProxyRuntime({ managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: REAL }], rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], egressMode: 'permissive', + onResponse: (info) => responses.push({ statusCode: info.statusCode, scrubbedKeys: info.scrubbedKeys }), }); const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); @@ -291,6 +293,9 @@ describe('proxy HTTPS MITM (end-to-end)', () => { expect(response).toContain('sk-stub-PLACEHOLDER'); expect(response).not.toContain(REAL); + // ...and onResponse reports which key was scrubbed (for the live proxy-start log). + expect(responses).toEqual([{ statusCode: 200, scrubbedKeys: ['API_KEY'] }]); + tlsSocket.destroy(); await runtime.stop(); await upstream.close(); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 2d1410590..a22836fdb 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -43,11 +43,25 @@ export type ProxyRuntimeContext = { stop: () => Promise; }; +/** Reported after an upstream response is forwarded — surfaces response-side scrubbing. */ +export type ProxyResponseInfo = { + host: string; + method: string; + path: string; + statusCode: number; + /** Managed item keys (names) whose real value appeared in the response and was scrubbed back to a placeholder. */ + scrubbedKeys: Array; + /** True for an unbounded/streamed body (scrubbed chunk-by-chunk). */ + streamed?: boolean; +}; + export type StartLocalProxyRuntimeInput = { managedItems: Array; rules: Array; egressMode: ProxyEgressMode; onActivity?: (activity: ProxyActivity) => void; + /** Called after an upstream response is forwarded, with any keys scrubbed from it. */ + onResponse?: (info: ProxyResponseInfo) => void; /** * Called when a request matches a `require-approval` rule. Must fail closed * (deny on timeout/error). Absent ⇒ require-approval requests are denied. @@ -316,6 +330,15 @@ function findRealLeak(text: string, managedItems: Array): Prox return managedItems.find((item) => item.realValue.length > 0 && text.includes(item.realValue)); } +/** Item keys whose real value appears in `text` — i.e. the keys that get scrubbed back to placeholders. */ +function detectScrubbedKeys(text: string, managedItems: Array): Array { + const keys: Array = []; + for (const item of managedItems) { + if (item.realValue.length > 0 && text.includes(item.realValue)) keys.push(item.key); + } + return keys; +} + /** * Length of the longest suffix of `text` that is a strict prefix of some real * value — i.e. a partial real value that might complete in the next chunk and @@ -346,19 +369,29 @@ function pendingRealPrefixLen(text: string, managedItems: Array): Transform { +function createScrubbingTransform( + managedItems: Array, + matchedKeys?: Set, +): Transform { const decoder = new StringDecoder('utf8'); let carry = ''; + const note = (text: string) => { + if (matchedKeys) for (const key of detectScrubbedKeys(text, managedItems)) matchedKeys.add(key); + }; return new Transform({ transform(chunk, _enc, cb) { - const scrubbed = replaceRealWithPlaceholders(carry + decoder.write(chunk as Buffer), managedItems); + const decoded = carry + decoder.write(chunk as Buffer); + note(decoded); + const scrubbed = replaceRealWithPlaceholders(decoded, managedItems); const hold = pendingRealPrefixLen(scrubbed, managedItems); const emitLen = scrubbed.length - hold; carry = scrubbed.slice(emitLen); cb(null, Buffer.from(scrubbed.slice(0, emitLen), 'utf8')); }, flush(cb) { - cb(null, Buffer.from(replaceRealWithPlaceholders(carry + decoder.end(), managedItems), 'utf8')); + const decoded = carry + decoder.end(); + note(decoded); + cb(null, Buffer.from(replaceRealWithPlaceholders(decoded, managedItems), 'utf8')); }, }); } @@ -368,8 +401,23 @@ function forwardUpstreamResponseWithRedaction( clientRes: http.ServerResponse, managedItems: Array, shouldRedact: boolean, + responseCtx?: { host: string; method: string; path: string; onResponse?: (info: ProxyResponseInfo) => void }, ) { const statusCode = upstreamRes.statusCode ?? 502; + const onResponse = responseCtx?.onResponse; + const report = (scrubbedKeys: Iterable, streamed: boolean) => { + if (!onResponse || !responseCtx) return; + onResponse({ + host: responseCtx.host, + method: responseCtx.method, + path: responseCtx.path, + statusCode, + scrubbedKeys: [...new Set(scrubbedKeys)], + ...(streamed ? { streamed: true } : {}), + }); + }; + // Detect keys reflected in the (original) response headers, scrubbed regardless of body path. + const headerKeys = shouldRedact ? detectScrubbedKeys(JSON.stringify(upstreamRes.headers), managedItems) : []; const outgoingHeaders = shouldRedact ? redactOutgoingHeaders(upstreamRes.headers, managedItems) : { ...upstreamRes.headers }; @@ -388,8 +436,13 @@ function forwardUpstreamResponseWithRedaction( clientRes.writeHead(statusCode, outgoingHeaders); if (canScrubStream) { - upstreamRes.pipe(createScrubbingTransform(managedItems)).pipe(clientRes); + const matched = new Set(headerKeys); + const transform = createScrubbingTransform(managedItems, matched); + transform.on('end', () => report(matched, true)); + upstreamRes.pipe(transform).pipe(clientRes); } else { + // Passthrough (compressed/binary/unscanned body) — only header reflection is visible. + report(headerKeys, false); upstreamRes.pipe(clientRes); } return; @@ -402,6 +455,7 @@ function forwardUpstreamResponseWithRedaction( upstreamRes.on('end', () => { const originalBody = Buffer.concat(chunks).toString('utf8'); + const bodyKeys = detectScrubbedKeys(originalBody, managedItems); const redactedBody = replaceRealWithPlaceholders(originalBody, managedItems); // Fail-safe (Invariant #6): if a real value somehow survived scrubbing, do @@ -424,6 +478,7 @@ function forwardUpstreamResponseWithRedaction( clientRes.writeHead(statusCode, headersForWrite); clientRes.end(redactedBuffer); + report([...headerKeys, ...bodyKeys], false); }); upstreamRes.on('error', () => { @@ -453,6 +508,7 @@ export async function startLocalProxyRuntime({ rules: initialRules, egressMode: initialEgressMode, onActivity, + onResponse, approvalProvider, }: StartLocalProxyRuntimeInput): Promise { // Mutable so `reconfigure` can hot-swap the enforced policy on a live proxy. @@ -587,7 +643,12 @@ export async function startLocalProxyRuntime({ headers: upstreamHeaders, ...(t.isHttps ? buildVerifiedUpstreamOptions() : {}), }, (upstreamRes) => { - forwardUpstreamResponseWithRedaction(upstreamRes, res, hostItems, shouldRewrite); + forwardUpstreamResponseWithRedaction(upstreamRes, res, hostItems, shouldRewrite, { + host: t.host, + method: t.method, + path: t.pathOnly, + onResponse, + }); }); upstreamReq.on('error', () => { From 4cd2a47fb67aa88e4d6c70a03b6df0b00676a6e3 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 19 Jun 2026 15:59:19 -0700 Subject: [PATCH 29/64] fix(proxy): keep the live log from corrupting the approval prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy-start request log and the TTY approval prompt share stderr, so a concurrent request's log line could clobber the readline prompt. Defer log lines while a prompt is awaiting input and flush them once it resolves. Also surface a clear hint (instead of a silent deny) when the prompt hits EOF — which happens when `proxy start` isn't the foreground of an interactive terminal (backgrounded, supervised, or stdin redirected). --- .../varlock/src/cli/commands/proxy.command.ts | 43 +++++++++++++++++-- packages/varlock/src/proxy/approval.test.ts | 15 +++++++ packages/varlock/src/proxy/approval.ts | 12 ++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index cf067442f..67f76d157 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -358,6 +358,40 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise = []; + +function emitProxyLog(line: string): void { + if (activeApprovalPrompts > 0) { + deferredProxyLogLines.push(line); + return; + } + console.error(line); +} + +function flushDeferredProxyLogs(): void { + if (activeApprovalPrompts > 0) return; + for (const line of deferredProxyLogLines.splice(0)) console.error(line); +} + +/** Wrap an approval provider so the live log defers (rather than corrupts) the TTY while it prompts. */ +function guardApprovalPromptForLogging(inner: ApprovalProvider): ApprovalProvider { + return { + async requestApproval(req) { + activeApprovalPrompts += 1; + try { + return await inner.requestApproval(req); + } finally { + activeApprovalPrompts -= 1; + flushDeferredProxyLogs(); + } + }, + }; +} + /** A one-line live log of a request decision: `→ POST host/path inject: KEY` (or `✗ … blocked-egress`). */ function formatProxyRequestLog(a: ProxyActivity): string { const arrow = a.blocked ? '✗' : '→'; @@ -419,10 +453,10 @@ async function createRuntimeAndSession(opts: { onActivity: (activity) => { statsWriter.onActivity(activity); auditLog.record(activity); - if (opts.logRequests) console.error(formatProxyRequestLog(activity)); + if (opts.logRequests) emitProxyLog(formatProxyRequestLog(activity)); }, onResponse: opts.logRequests - ? (info) => console.error(formatProxyResponseLog(info)) + ? (info) => emitProxyLog(formatProxyResponseLog(info)) : undefined, }); const session = await createProxySessionRecord({ @@ -842,8 +876,9 @@ async function startAction(ctx: any) { entryPaths: ctx.values.path, // The proxy owns this terminal (the agent runs elsewhere and routes through // it), so require-approval requests can prompt here — and session/duration - // approvals can be remembered as standing grants. - approvalProvider: createTtyApprovalProvider(), + // approvals can be remembered as standing grants. Wrapped so the live request + // log defers while the prompt is reading input (shared TTY). + approvalProvider: guardApprovalPromptForLogging(createTtyApprovalProvider()), enableApprovalGrants: true, reloadable: true, // The daemon owns this terminal, so tail a live per-request/response log here. diff --git a/packages/varlock/src/proxy/approval.test.ts b/packages/varlock/src/proxy/approval.test.ts index 5b99c81bf..aa39681e5 100644 --- a/packages/varlock/src/proxy/approval.test.ts +++ b/packages/varlock/src/proxy/approval.test.ts @@ -162,4 +162,19 @@ describe('createTtyApprovalProvider', () => { // never write input → the timeout fires and denies expect((await provider.requestApproval(req())).approved).toBe(false); }); + + test('on EOF without an answer, denies and hints to run in the foreground', async () => { + const input = ttyInput(); + const output = new PassThrough(); + let prompt = ''; + output.on('data', (c: Buffer) => { + prompt += c.toString('utf8'); + }); + const provider = createTtyApprovalProvider({ input, output }); + const pending = provider.requestApproval(req()); + input.end(); // EOF — the terminal couldn't be read (e.g. backgrounded daemon) + const decision = await pending; + expect(decision.approved).toBe(false); + expect(prompt).toContain('foreground of an interactive terminal'); + }); }); diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts index 1b87484f8..bfa0d5919 100644 --- a/packages/varlock/src/proxy/approval.ts +++ b/packages/varlock/src/proxy/approval.ts @@ -235,6 +235,7 @@ export function createTtyApprovalProvider(opts?: { } Approve? ${options.join(' ')} `; const rl = readline.createInterface({ input, output }); + let answered = false; const answer = await new Promise((resolve) => { const timer = setTimeout(() => { output.write('\n (timed out — denied)\n'); @@ -242,11 +243,22 @@ export function createTtyApprovalProvider(opts?: { }, timeoutMs); timer.unref?.(); rl.question(prompt, (a) => { + answered = true; clearTimeout(timer); resolve(a); }); rl.on('close', () => { clearTimeout(timer); + // 'close' without an answer means EOF on the terminal — which happens + // when `proxy start` isn't the foreground process of an interactive + // terminal (e.g. started with `&`, under a supervisor, or stdin + // redirected). Surface that rather than denying silently. + if (!answered) { + output.write( + '\n (couldn\'t read your answer — run `varlock proxy start` in the ' + + 'foreground of an interactive terminal to approve requests)\n', + ); + } resolve(''); }); }); From 15bc0c720a3daf16ed73f6f48aed361c280649ae Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 19 Jun 2026 23:13:22 -0700 Subject: [PATCH 30/64] feat(proxy): color the proxy-start request log Color-code the live log for readability: green/red request arrows by decision, cyan response arrows, status by class (2xx/3xx green, 4xx yellow, 5xx red), injected/scrubbed key names highlighted, method+path dimmed so the host stands out. Uses ansis (respects NO_COLOR / non-TTY). --- .bumpy/proxy-start-request-log.md | 7 ++-- .../varlock/src/cli/commands/proxy.command.ts | 36 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.bumpy/proxy-start-request-log.md b/.bumpy/proxy-start-request-log.md index 22541e833..e0f8fbbf6 100644 --- a/.bumpy/proxy-start-request-log.md +++ b/.bumpy/proxy-start-request-log.md @@ -4,9 +4,10 @@ varlock: patch `varlock proxy start` now prints a live request log to its terminal. -Each proxied request shows a one-line decision with the keys injected on the way -out, and each forwarded response shows its status and any keys scrubbed back to -placeholders on the way in: +Each proxied request shows a color-coded one-line decision with the keys injected +on the way out, and each forwarded response shows its status and any keys scrubbed +back to placeholders on the way in (`→` green / `✗` red request, `←` cyan response, +status colored by class, injected/scrubbed key names highlighted): ``` → GET httpbin.org/get inject: API_TOKEN diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 67f76d157..d71b939e5 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -1,5 +1,6 @@ import path from 'node:path'; +import ansis from 'ansis'; import { define } from 'gunshi'; import { gracefulExit } from 'exit-hook'; @@ -392,19 +393,40 @@ function guardApprovalPromptForLogging(inner: ApprovalProvider): ApprovalProvide }; } +/** Color an HTTP status by class: 2xx/3xx green, 4xx yellow, 5xx red. */ +function colorProxyStatus(code: number): string { + const text = String(code); + if (code >= 500) return ansis.red(text); + if (code >= 400) return ansis.yellow(text); + return ansis.green(text); +} + +/** `METHOD host/path` with the method + path dimmed so the host stands out. */ +function formatProxyTarget(method: string, host: string, pathName: string): string { + return `${ansis.dim(`${method} `)}${host}${ansis.dim(pathName)}`; +} + /** A one-line live log of a request decision: `→ POST host/path inject: KEY` (or `✗ … blocked-egress`). */ function formatProxyRequestLog(a: ProxyActivity): string { - const arrow = a.blocked ? '✗' : '→'; - const decision = a.decision === 'allow' ? '' : ` ${a.decision}`; - const inject = a.injectedKeys?.length ? ` inject: ${a.injectedKeys.join(', ')}` : ''; - return `${arrow} ${a.method} ${a.host}${a.path}${decision}${inject}`; + const arrow = a.blocked ? ansis.red('✗') : ansis.green('→'); + let decision = ''; + if (a.decision !== 'allow') { + decision = ` ${(a.blocked ? ansis.red : ansis.green)(a.decision)}`; + } + 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 one-line live log of a forwarded response: `← POST host/path 200 scrubbed: KEY`. */ function formatProxyResponseLog(info: ProxyResponseInfo): string { - const scrub = info.scrubbedKeys.length ? ` scrubbed: ${info.scrubbedKeys.join(', ')}` : ''; - const streamed = info.streamed ? ' (streamed)' : ''; - return `← ${info.method} ${info.host}${info.path} ${info.statusCode}${scrub}${streamed}`; + const arrow = ansis.cyan('←'); + const scrub = info.scrubbedKeys.length + ? ` ${ansis.dim('scrubbed:')} ${ansis.yellow(info.scrubbedKeys.join(', '))}` + : ''; + const streamed = info.streamed ? ansis.dim(' (streamed)') : ''; + return `${arrow} ${formatProxyTarget(info.method, info.host, info.path)} ${colorProxyStatus(info.statusCode)}${scrub}${streamed}`; } async function createRuntimeAndSession(opts: { From 9cc1acde34d8a476a33617458cfe7a1c4fd1e918 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 19 Jun 2026 23:35:49 -0700 Subject: [PATCH 31/64] feat(proxy): add `proxy rules` summary, document limitations - `varlock proxy rules` prints the effective @proxy config (rules + per-secret mode) without starting a proxy. - load json-full proxy metadata now uses the unified detector (accurate even if the env marker is stripped). - Document the proxy's limitations (same-host/uid, best-effort response scrub on compressed/large bodies, verified-TLS-only injection, unauthenticated reload, TTY-only approvals) and frame it as a preview. --- .bumpy/proxy-rules-command.md | 10 +++ .../src/content/docs/guides/proxy.mdx | 15 ++++ .../content/docs/reference/cli-commands.mdx | 3 +- .../varlock/src/cli/commands/load.command.ts | 11 ++- .../varlock/src/cli/commands/proxy.command.ts | 89 ++++++++++++++++++- 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 .bumpy/proxy-rules-command.md diff --git a/.bumpy/proxy-rules-command.md b/.bumpy/proxy-rules-command.md new file mode 100644 index 000000000..55c34131c --- /dev/null +++ b/.bumpy/proxy-rules-command.md @@ -0,0 +1,10 @@ +--- +varlock: patch +--- + +Add `varlock proxy rules` to summarize the effective `@proxy` configuration. + +Prints the routing rules (host / path / method, block / approval) and each +secret's mode — proxied (placeholder, injected), placeholder (sensitive, no +rule), passthrough (real value), or omit — without starting a proxy. Handy for +verifying a schema and seeing what an agent could and couldn't reach. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 3df56005b..9d3343b1a 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -7,6 +7,10 @@ The credential proxy lets you run an AI agent (or any untrusted tool) so that it This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it — coding agents, MCP servers, third-party CLIs, or scripts. +:::caution[Preview] +The credential proxy is an early preview. The core protection (placeholder isolation + verified-identity wire injection) is solid, but there are real limits to what it covers and it runs on the same machine/user as the agent. Read [Limitations](#limitations) before relying on it as a security boundary. +::: + ```env-spec title=".env.schema" # @enableProxy(egress="permissive") # --- @@ -254,6 +258,17 @@ When matched under `proxy start`, the request is held and you're prompted to app The object form implies approval is required; `approval=true` is shorthand for the defaults. +## Limitations + +The proxy is a local, same-machine tool. Know what it does and doesn't cover before relying on it: + +- **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *reading* a secret it's *using*, but it is **not a sandbox** — it doesn't isolate the filesystem, other processes, or memory. Pair it with an OS sandbox/container if you need stronger isolation. +- **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed** — the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection — the agent only ever holds a placeholder — is unaffected. +- **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream — by design. +- **Live policy reload is unauthenticated.** Any process running as your user can trigger a `proxy refresh`. On a shared-uid machine this is not a trust boundary; a real out-of-band approver is planned to close it. +- **Approvals are a TTY-only preview.** See [Approvals (experimental)](#approvals-experimental). +- **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely — it just won't get a working secret. + ## Reference - [`@enableProxy`](/reference/root-decorators/#enableproxy) and [`@proxy`](/reference/root-decorators/#proxy) root decorators diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index 0b3f69403..a2b9443e2 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -647,7 +647,8 @@ varlock proxy [options] **Subcommands:** - `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. -- `start`: Start a long-lived proxy session that owns the terminal (interactive approval prompts appear here). Stop with `Ctrl+C`. +- `start`: Start a long-lived proxy session that owns the terminal (interactive approval prompts and a live request log appear here). Stop with `Ctrl+C`. +- `rules`: Print a static summary of the effective `@proxy` configuration — the rules (host/path/method, block/approval) and each secret's mode (proxied / placeholder / passthrough / omit) — without starting a proxy. - `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). - `status`: List active proxy sessions. - `audit`: Print a session's request audit log (no secret values). diff --git a/packages/varlock/src/cli/commands/load.command.ts b/packages/varlock/src/cli/commands/load.command.ts index 75ccbe57b..c52f7c9e5 100644 --- a/packages/varlock/src/cli/commands/load.command.ts +++ b/packages/varlock/src/cli/commands/load.command.ts @@ -15,6 +15,7 @@ import { PROXY_SESSION_ID_ENV_VAR, PROXY_SESSION_UUID_ENV_VAR, } from '../../proxy/env-vars'; +import { getActiveProxySession } from '../../proxy/session-registry'; export const commandSpec = define({ name: 'load', @@ -197,12 +198,16 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = // this is an inspection dump (not the injected blob), so include @internal items — // they're flagged with isInternal and redacted below like any other sensitive value const serialized = envGraph.getSerializedGraph({ includeInternal: true }); - if (process.env[PROXY_CHILD_ENV_VAR] === '1') { + // Detect the proxy context via the unified resolver (env marker → session + // token → ancestry), so the annotation is accurate even if the child scrubbed + // the env marker. + const proxySession = await getActiveProxySession().catch(() => undefined); + if (proxySession || process.env[PROXY_CHILD_ENV_VAR] === '1') { (serialized as any).runtime = { proxy: { active: true, - sessionId: process.env[PROXY_SESSION_ID_ENV_VAR], - sessionUuid: process.env[PROXY_SESSION_UUID_ENV_VAR], + sessionId: proxySession?.id ?? process.env[PROXY_SESSION_ID_ENV_VAR], + sessionUuid: proxySession?.uuid ?? process.env[PROXY_SESSION_UUID_ENV_VAR], }, }; } diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index d71b939e5..7b893ecbe 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -117,6 +117,7 @@ Proxy command surface: varlock proxy run --session abc12 -- claude # attach to a specific session (approvals prompt in its terminal) varlock proxy run --new -- claude # force a fresh, separate proxy varlock proxy start + varlock proxy rules # summarize the effective @proxy config (no proxy started) varlock proxy env --session abc12 varlock proxy status varlock proxy audit --session abc12 @@ -275,7 +276,7 @@ function getAction(ctx: any): string { if (!action) { throw new CliExitError( 'Missing proxy action.', - { suggestion: 'Use one of: run, start, env, status, audit, refresh, stop' }, + { suggestion: 'Use one of: run, start, rules, env, status, audit, refresh, stop' }, ); } return action; @@ -1193,6 +1194,88 @@ async function auditAction(ctx: any) { } } +/** Human-readable gate for a rule: `block`, `approval (each=…, max=…)`, or empty. */ +function describeProxyRuleGate(rule: ProxyRule): string { + if (rule.block) return ansis.red('block (deny)'); + if (rule.approval) { + const each = rule.approval.each ?? 'endpoint'; + let max: string; + if (rule.approval.maxDurationMs === 0) max = 'always-ask'; + else if (rule.approval.maxDurationMs === undefined) max = 'session'; + else max = `${Math.max(1, Math.round(rule.approval.maxDurationMs / 60_000))}m`; + return ansis.yellow(`approval (each=${each}, max=${max})`); + } + return ''; +} + +/** + * Print a static summary of the effective `@proxy` configuration — the rules and + * each secret's mode — without starting a proxy. Useful for verifying a schema + * after the fail-closed validation, and for seeing what an agent would and + * wouldn't be able to reach. + */ +async function rulesAction(ctx: any) { + const envGraph = await loadVarlockEnvGraph({ + entryFilePaths: ctx.values.path, + // This command inspects the schema; don't subject it to the nested guard. + skipProxyFingerprintGuard: true, + }); + checkForSchemaErrors(envGraph); + checkForNoEnvFiles(envGraph); + await envGraph.resolveEnvValues(); // values aren't printed; resolution classifies items + + const egress = envGraph.getSerializedGraph().settings?.proxyEgress ?? 'permissive'; + const rules = await envGraph.getProxyRules(); + const managedItems = await envGraph.getProxyManagedItems(); + const managedKeys = new Set(managedItems.map((item) => item.key)); + const { placeholderByKey, omittedKeys } = await computeProxyChildView(envGraph, managedItems); + const omittedSet = new Set(omittedKeys); + + console.log(ansis.bold('Proxy configuration')); + console.log(` egress mode: ${egress === 'strict' ? ansis.yellow('strict') : 'permissive'}`); + console.log(''); + + console.log(ansis.bold(`Rules (${rules.length})`)); + if (!rules.length) { + console.log(ansis.dim(' (none — add @proxy(domain=...) to route a secret)')); + } else { + for (const rule of rules) { + const target = rule.domain.join(', ') + + (rule.path ? ` ${ansis.dim(rule.path)}` : '') + + (rule.method?.length ? ` ${ansis.dim(`[${rule.method.join(',')}]`)}` : ''); + const parts = [target, describeProxyRuleGate(rule)]; + // A block rule denies the request, so it never injects — don't imply otherwise. + if (rule.itemKeys.length && !rule.block) parts.push(`→ inject ${ansis.yellow(rule.itemKeys.join(', '))}`); + console.log(` • ${parts.filter(Boolean).join(' ')}`); + } + } + console.log(''); + + const secrets: Array<{ key: string; label: string }> = []; + for (const key of envGraph.sortedConfigKeys) { + if (isVarlockReservedKey(key)) continue; + const item = envGraph.configSchema[key]; + if (!item) continue; + if (managedKeys.has(key)) { + secrets.push({ key, label: `${ansis.green('proxied')} — placeholder; real value injected on matching hosts` }); + } else if (omittedSet.has(key)) { + secrets.push({ key, label: `${ansis.yellow('omit')} — withheld from the child entirely` }); + } else if (getProxyValueMode(item) === 'passthrough') { + secrets.push({ key, label: `${ansis.red('passthrough')} — real value sent to the child` }); + } else if (placeholderByKey[key]) { + secrets.push({ key, label: `${ansis.cyan('placeholder')} — sensitive, no rule (not injected anywhere)` }); + } + } + + console.log(ansis.bold(`Secrets (${secrets.length})`)); + if (!secrets.length) { + console.log(ansis.dim(' (none)')); + } else { + const width = Math.max(...secrets.map((s) => s.key.length)); + for (const { key, label } of secrets) console.log(` ${key.padEnd(width)} ${label}`); + } +} + export const commandFn: TypedGunshiCommandFn = async (ctx) => { const action = getAction(ctx); @@ -1201,6 +1284,8 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = return await runAction(ctx); case 'start': return await startAction(ctx); + case 'rules': + return await rulesAction(ctx); case 'env': return await envAction(ctx); case 'status': @@ -1214,7 +1299,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = default: throw new CliExitError( `Unknown proxy action "${action}".`, - { suggestion: 'Use one of: run, start, env, status, audit, refresh, stop' }, + { suggestion: 'Use one of: run, start, rules, env, status, audit, refresh, stop' }, ); } }; From c5ad3c07989c3b6d686679523235a42927797cf2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 25 Jun 2026 22:40:49 -0700 Subject: [PATCH 32/64] fix(proxy): close secret-leak blockers, gate reload, post-review hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes found while reviewing the proxy MVP: - Invariant #1 leaked under Bun (the compiled-binary runtime): https.request flushes the request to a wrong-identity upstream before checkServerIdentity rejects. Verify the upstream TLS identity explicitly (chain + checkServerIdentity) on a connection we control, then pin the real request to the proven peer IP (Bun won't accept a handed-in socket). Adds scripts/proxy-tls-bun-check.ts (`bun run test:proxy:bun`) since vitest runs under Node and can't catch this. - A torn/corrupt read of session.json re-exposed real secrets to a proxied child: writes are now atomic (tmp+rename), parseSessionRecord no longer deletes the record on a parse failure, and resolution fails closed when the proxy-child marker is set but no session resolves (the deeper fail-open root cause). - Hot-reload (`proxy refresh`) is now opt-in via `proxy start --allow-reload`, off by default — otherwise a same-uid agent can self-approve a schema edit and defeat the fingerprint guard. - computeProxyChildView default-denies a sensitive item whose resolver was unresolved at snapshot time (was skipped → could re-resolve to a real value). - parseHostPort handles bracketed IPv6; `proxy stop` errors on --all + --session; @public + @proxy warns instead of silently overriding; typegen marks @proxy items sensitive; CliExitError separates details from suggestion. --- .bumpy/proxy-refresh-hot-reload.md | 4 +- packages/varlock/package.json | 1 + .../varlock/scripts/proxy-tls-bun-check.ts | 125 ++++++++++++++++ .../varlock/src/cli/commands/proxy.command.ts | 102 +++++++++---- .../varlock/src/cli/helpers/exit-error.ts | 3 + .../cli/helpers/proxy-schema-fingerprint.ts | 4 +- .../varlock/src/env-graph/lib/config-item.ts | 13 ++ packages/varlock/src/lib/load-graph.ts | 18 ++- .../varlock/src/lib/test/load-graph.test.ts | 30 ++++ packages/varlock/src/proxy/runtime-proxy.ts | 140 ++++++++++++++---- .../varlock/src/proxy/session-registry.ts | 39 +++-- 11 files changed, 411 insertions(+), 68 deletions(-) create mode 100644 packages/varlock/scripts/proxy-tls-bun-check.ts diff --git a/.bumpy/proxy-refresh-hot-reload.md b/.bumpy/proxy-refresh-hot-reload.md index 6fb1c6eef..7d770e257 100644 --- a/.bumpy/proxy-refresh-hot-reload.md +++ b/.bumpy/proxy-refresh-hot-reload.md @@ -2,6 +2,6 @@ varlock: patch --- -`varlock proxy refresh` hot-reloads a running proxy. +`varlock proxy refresh` hot-reloads a running proxy (opt-in). -Editing your schema and running `varlock proxy refresh` re-resolves it in the proxy's trusted context and swaps the live policy — rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. `refresh` now blocks until the reload completes and then prints how to pick up the new variables (`varlock load` / `varlock run`). Works for both `proxy start` daemons and self-owned `proxy run` sessions. +Start a daemon with `varlock proxy start --allow-reload`, then editing your schema and running `varlock proxy refresh` re-resolves it in the proxy's trusted context and swaps the live policy — rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. It is **off by default**: the reload channel is unauthenticated on a shared uid, so without this gate a same-uid agent could trigger a refresh to self-approve its own schema edit. When disabled, restart the proxy to apply schema changes. diff --git a/packages/varlock/package.json b/packages/varlock/package.json index 9ceb38fae..8e179ec02 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -28,6 +28,7 @@ "sync-skill": "bun run scripts/sync-skill.ts", "test": "vitest", "test:ci": "vitest --run", + "test:proxy:bun": "bun run scripts/proxy-tls-bun-check.ts", "lint": "eslint .", "lint:fix": "bun run lint --fix", "typecheck": "tsc --noEmit" diff --git a/packages/varlock/scripts/proxy-tls-bun-check.ts b/packages/varlock/scripts/proxy-tls-bun-check.ts new file mode 100644 index 000000000..39a51f4e5 --- /dev/null +++ b/packages/varlock/scripts/proxy-tls-bun-check.ts @@ -0,0 +1,125 @@ +/** + * Invariant #1 regression check — runs under **Bun** (the runtime the compiled + * CLI binary uses), which the Node-based vitest suite cannot exercise. + * + * Bun's `https.request` flushes the request (Authorization header + body) to a + * wrong-identity upstream *before* `checkServerIdentity` rejects — so a TLS + * identity check that relies on the request's own pre-write gating leaks the + * secret in the shipped binary while staying green under Node. This check proves + * the runtime proxy: + * (a) injects the real secret to a correctly-identified upstream, and + * (b) NEVER transmits the secret to an upstream whose cert is for a different + * host (the DNS-poison / host-rebind case). + * + * Run with: bun run scripts/proxy-tls-bun-check.ts (exits non-zero on failure) + */ +import https from 'node:https'; +import net from 'node:net'; +import tls from 'node:tls'; +import { readFileSync } from 'node:fs'; + +import { startLocalProxyRuntime } from '../src/proxy/runtime-proxy'; +import { createEphemeralCa, createHostCert, type EphemeralCa } from '../src/proxy/cert-authority'; + +// A DNS name that resolves to loopback — avoids IP-literal SNI quirks in the +// hand-rolled test client while still exercising the real CONNECT/MITM path. +const HOST = 'localhost'; +const REAL_KEY = 'sk-stub-REALKEY-must-never-leak'; +const PLACEHOLDER = 'sk-stub-PLACEHOLDER'; + +let upstreamCa: EphemeralCa; + +async function openMitmTunnel(proxyUrl: string, proxyCaPem: string, port: number): Promise { + const proxy = new URL(proxyUrl); + const raw = net.connect(Number(proxy.port), proxy.hostname); + await new Promise((resolve, reject) => { + raw.once('error', reject); + raw.once('connect', () => resolve()); + }); + await new Promise((resolve, reject) => { + raw.once('data', (c: Buffer) => { + const statusLine = c.toString('utf8').split('\r\n')[0] ?? ''; + if (/^HTTP\/1\.\d 200/.test(statusLine)) resolve(); + else reject(new Error('CONNECT failed')); + }); + raw.write(`CONNECT ${HOST}:${port} HTTP/1.1\r\nHost: ${HOST}:${port}\r\n\r\n`); + }); + const s = tls.connect({ + socket: raw, servername: HOST, host: HOST, ca: [proxyCaPem], + }); + await new Promise((resolve, reject) => { + s.once('secureConnect', () => resolve()); + s.once('error', reject); + }); + return s; +} + +/** Returns whether the upstream received the REAL key in its Authorization header. */ +async function runScenario(upstreamCertHost: string): Promise { + const leaf = await createHostCert(upstreamCa, upstreamCertHost); + let upstreamGotRealKey = false; + const server = https.createServer({ key: leaf.keyPem, cert: leaf.certPem }, (req, res) => { + if (String(req.headers.authorization ?? '').includes(REAL_KEY)) upstreamGotRealKey = true; + res.end('ok'); + }); + server.on('tlsClientError', () => { /* a rejected upstream handshake is the point */ }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve(); + }); + }); + const port = (server.address() as net.AddressInfo).port; + + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: PLACEHOLDER, realValue: REAL_KEY }], + rules: [{ domain: [HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + }); + const proxyCaPem = readFileSync(runtime.env.NODE_EXTRA_CA_CERTS!, 'utf8'); + try { + const sock = await openMitmTunnel(runtime.env.HTTP_PROXY!, proxyCaPem, port); + await new Promise((resolve) => { + let idle: ReturnType; + sock.on('data', () => { + clearTimeout(idle); + idle = setTimeout(resolve, 250); + }); + sock.on('close', () => resolve()); + sock.on('error', () => resolve()); + sock.write(`GET / HTTP/1.1\r\nHost: ${HOST}:${port}\r\nConnection: close\r\nAuthorization: Bearer ${PLACEHOLDER}\r\n\r\n`); + setTimeout(resolve, 2500); + }); + } catch { /* tunnel/handshake failure = fail-closed, which is fine */ } + await runtime.stop(); + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + return upstreamGotRealKey; +} + +async function main() { + if (!process.versions.bun) { + console.error('This check must run under Bun (the compiled-binary runtime). Use: bun run scripts/proxy-tls-bun-check.ts'); + process.exit(2); + } + // Make the proxy's verification trust the stub upstream CA (in production these + // are real public roots). The proxy reads https.globalAgent.options.ca. + upstreamCa = await createEphemeralCa(); + https.globalAgent.options.ca = [...tls.rootCertificates, upstreamCa.certPem]; + + const injectedToVerifiedHost = await runScenario(HOST); // cert matches → inject + const leakedToWrongHost = await runScenario('wrong.example'); // cert mismatch → must NOT leak + + const ok = injectedToVerifiedHost && !leakedToWrongHost; + console.log(`[bun ${process.versions.bun}] inject-to-verified-host=${injectedToVerifiedHost} (want true), leak-to-wrong-cert-host=${leakedToWrongHost} (want false)`); + if (ok) { + console.log('PASS: Invariant #1 holds under Bun — secret injected only to the verified upstream identity.'); + process.exit(0); + } + console.error('FAIL: Invariant #1 violated under Bun.'); + process.exit(1); +} + +await main(); diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 7b893ecbe..d7d534d55 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -110,6 +110,12 @@ export const commandSpec = define({ type: 'boolean', description: 'For `proxy run`: start a fresh proxy instead of attaching to a running one for this directory', }, + 'allow-reload': { + type: 'boolean', + description: 'For `proxy start`/`run`: enable live policy hot-reload via `proxy refresh`. Off by default — ' + + 'the reload channel is unauthenticated on a shared uid, so a same-uid agent could self-approve a schema ' + + 'edit. Only enable it alongside a sandbox (or once the out-of-band approver ships).', + }, }, examples: ` Proxy command surface: @@ -244,7 +250,7 @@ export async function computeProxyChildView( if (isVarlockReservedKey(key)) continue; if (managedKeys.has(key)) continue; const item = envGraph.configSchema[key]; - if (!item || item.resolvedValue === undefined) continue; + if (!item) continue; const mode = getProxyValueMode(item); if (mode === 'passthrough') continue; // inject the real value if (mode === 'omit') { @@ -252,6 +258,15 @@ export async function computeProxyChildView( continue; } if (!item.isSensitive) continue; // non-sensitive with no policy → injected normally + // Default-deny: if a sensitive item's value didn't resolve at snapshot time + // (its resolver threw / returned undefined), we can't base a placeholder on it + // AND can't assume it stays unresolved in the child — a later successful + // re-resolution (e.g. a warm credential session) would surface the REAL value. + // Omit it so the child gets `unset`, never the real secret. + if (item.resolvedValue === undefined) { + omittedKeys.push(key); + continue; + } const { placeholder } = await generateProxyPlaceholderForItem(item, usedPlaceholders); placeholderByKey[key] = placeholder; // sensitive default → placeholder } @@ -737,6 +752,10 @@ async function runAction(ctx: any) { await removeProxySessionAttachment(session.uuid, process.pid).catch(() => undefined); }; } else { + // Hot-reload is opt-in (see startAction). For a one-shot `proxy run` it's + // rarely needed — the next invocation re-reads the schema — so it stays off + // unless `--allow-reload` is passed. + const allowReload = ctx.values['allow-reload'] === true; const created = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, @@ -745,21 +764,23 @@ async function runAction(ctx: any) { // require-approval requests fail closed. Use `proxy start` (or attach to one) // for interactive approval. approvalProvider: createAutoDenyApprovalProvider(), - reloadable: true, + reloadable: allowReload, }); session = created.session; statsWriter = created.statsWriter; console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); - // This run owns its proxy, so it services its own `proxy refresh` reloads. - // Notices go to stderr (the child owns stdout). - const reloadServicer = startReloadServicer({ - session, - runtime: created.runtime, - defaultEntryPaths: ctx.values.path, - log: (message) => console.error(message), - }); + // This run owns its proxy, so it services its own `proxy refresh` reloads when + // enabled. Notices go to stderr (the child owns stdout). + const reloadServicer = allowReload + ? startReloadServicer({ + session, + runtime: created.runtime, + defaultEntryPaths: ctx.values.path, + log: (message) => console.error(message), + }) + : undefined; cleanup = async () => { - reloadServicer.stop(); + reloadServicer?.stop(); await created.statsWriter.flushNow(); created.statsWriter.stop(); await created.runtime.stop().catch(() => undefined); @@ -891,6 +912,10 @@ async function runAction(ctx: any) { } async function startAction(ctx: any) { + // Hot-reload is opt-in: the reload channel is unauthenticated on a shared uid, + // so a same-uid agent could `proxy refresh` to self-approve a schema edit and + // defeat the fingerprint guard. Off by default; restart to change policy. + const allowReload = ctx.values['allow-reload'] === true; const policy = await prepareProxyPolicy(ctx.values.path); const { runtime, session, statsWriter, auditLog, @@ -903,7 +928,7 @@ async function startAction(ctx: any) { // log defers while the prompt is reading input (shared TTY). approvalProvider: guardApprovalPromptForLogging(createTtyApprovalProvider()), enableApprovalGrants: true, - reloadable: true, + reloadable: allowReload, // The daemon owns this terminal, so tail a live per-request/response log here. logRequests: true, }); @@ -911,23 +936,30 @@ async function startAction(ctx: any) { console.log(`Started proxy session ${session.id} (${session.uuid})`); console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); - console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); + if (allowReload) { + console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); + } else { + console.log('Schema edits require a restart (hot-reload disabled; enable with `--allow-reload`).'); + } console.log('Live request log (→ request · ← response):'); // Service `proxy refresh` requests: hot-reload the live policy without dropping - // the proxy. The daemon owns this terminal, so reload notices print here. - const reloadServicer = startReloadServicer({ - session, - runtime, - defaultEntryPaths: ctx.values.path, - log: (message) => console.log(message), - }); + // the proxy. The daemon owns this terminal, so reload notices print here. Only + // runs when reload is explicitly enabled. + const reloadServicer = allowReload + ? startReloadServicer({ + session, + runtime, + defaultEntryPaths: ctx.values.path, + log: (message) => console.log(message), + }) + : undefined; let cleanedUp = false; const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; - reloadServicer.stop(); + reloadServicer?.stop(); await statsWriter.flushNow(); statsWriter.stop(); await runtime.stop().catch(() => undefined); @@ -1032,13 +1064,18 @@ async function refreshAction(ctx: any) { throw new CliExitError((error as Error).message); }); - // Only a session that owns a live runtime (`proxy start`, or a self-owned - // `proxy run`) can hot-reload. An attached run has no runtime of its own — it - // routes through a daemon, which is the session resolved here anyway. + // Hot-reload is opt-in and only a session that owns a live runtime (`proxy + // start`, or a self-owned `proxy run`) started with `--allow-reload` can do it. + // An attached run has no runtime of its own — it routes through a daemon, which + // is the session resolved here anyway. if (!session.reloadable) { throw new CliExitError( - `Proxy session ${session.id} is not reloadable.`, - { suggestion: 'Refresh applies to a `proxy start` daemon (or a self-owned `proxy run`).' }, + `Proxy session ${session.id} has hot-reload disabled.`, + { + suggestion: 'Restart the proxy to pick up schema edits, or start it with ' + + '`varlock proxy start --allow-reload` to enable live reloads. Note: the reload ' + + 'channel is unauthenticated on a shared uid — only enable it behind a sandbox.', + }, ); } if (!isProcessRunning(session.ownerPid)) { @@ -1105,13 +1142,22 @@ async function refreshAction(ctx: any) { } console.log(`✓ Schema change reloaded for proxy session ${session.id}.`); - console.log(' • Commands run via `varlock run -- …` now use the updated variables.'); - console.log(' • Run `varlock load` to see the current variable set (placeholders).'); + console.log(' • New requests through the proxy use the updated rules and secrets.'); + console.log(' • Newly launched `varlock proxy run -- …` children pick up the change; an already-running'); + console.log(' child keeps the env it started with (restart it to refresh its variables).'); } async function stopAction(ctx: any) { await cleanupStaleProxySessions(); + // Refuse an ambiguous target rather than silently honoring the destructive `--all`. + if (ctx.values.all && ctx.values.session) { + throw new CliExitError( + 'Pass either --all or --session, not both.', + { suggestion: 'Use `--session ` to stop one session, or `--all` to stop every session.' }, + ); + } + if (ctx.values.all) { const sessions = await listProxySessions(); if (!sessions.length) { diff --git a/packages/varlock/src/cli/helpers/exit-error.ts b/packages/varlock/src/cli/helpers/exit-error.ts index f5d275668..acd72909b 100644 --- a/packages/varlock/src/cli/helpers/exit-error.ts +++ b/packages/varlock/src/cli/helpers/exit-error.ts @@ -26,6 +26,9 @@ export class CliExitError extends Error { if (this.more?.details) { msg += joinAndCompact(_.castArray(this.more?.details), '\n'); + // separate the details block from the suggestion (otherwise they render glued + // together, e.g. "…recover real values.Re-approve the change…") + msg += '\n'; } if (this.more?.suggestion) { diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index 81f5c3939..f9c514de8 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -125,8 +125,8 @@ export async function enforceProxySchemaFingerprint( if (actual === expected) return; throw new CliExitError('Schema changed inside an active proxy session.', { - details: 'The resolved .env.schema shape no longer matches the fingerprint captured when the proxy session started. This guards against downgrading @sensitive items mid-session to recover real values.', - suggestion: 'Re-approve the change by running `varlock proxy refresh` from a trusted (non-proxied) context, then retry.', + details: 'The resolved .env.schema no longer matches the fingerprint captured when the proxy session started. This guards against editing the schema mid-session — e.g. downgrading @sensitive, or adding a new item that resolves a secret — to recover real values.', + suggestion: 'Restart the proxy from a trusted (non-proxied) context to pick up the change. (If it was started with `varlock proxy start --allow-reload`, run `varlock proxy refresh` from a trusted context instead.)', forceExit: true, }); } diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 348e30220..5573b0d5a 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -504,6 +504,14 @@ export class ConfigItem { // placeholder while the real value is injected at the wire, so force sensitivity // regardless of any @public / @sensitive=false signal. if (this.getDecFns('proxy').length > 0) { + // If the author explicitly made it public, warn that @proxy wins — otherwise + // the override is silent and surprising. + if (this._sensitiveExplicitlySet && !this._isSensitive) { + this._schemaErrors.push(new SchemaError( + '@proxy implies @sensitive — the @public / @sensitive=false on this item is overridden (its value is still proxied as a placeholder).', + { isWarning: true }, + )); + } this._isSensitive = true; this._sensitiveSource = 'proxy'; } @@ -927,6 +935,11 @@ export class ConfigItem { if (!foundSensitive && sensitiveFromDataType !== undefined) { isSensitive = sensitiveFromDataType; } + // @proxy forces sensitivity (same override as processSensitive) so generated + // types mark a @public @proxy item sensitive rather than contradicting runtime. + if (this.getDecFns('proxy').length > 0) { + isSensitive = true; + } } catch { // on error, fall back to default (sensitive=true, safe default) } diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index e7553976f..d85d46f3f 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -9,7 +9,8 @@ import { runWithWorkspaceInfo } from './workspace-utils'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { createDebug } from './debug'; import { parseOverrideProvenanceMetadata, selectOverrideValuesFromEnv } from './injected-env-provenance'; -import { getProxyResolutionViewForEnv } from '../proxy/session-registry'; +import { getActiveProxySession, getProxyResolutionViewForEnv } from '../proxy/session-registry'; +import { PROXY_CHILD_ENV_VAR } from '../proxy/env-vars'; import { enforceProxySchemaFingerprint } from '../cli/helpers/proxy-schema-fingerprint'; const debug = createDebug('varlock:load'); @@ -107,6 +108,21 @@ export async function loadVarlockEnvGraph(opts?: { skipProxyFingerprintGuard?: boolean, }) { const runtimeOverrideValues = getGraphEnvOverridesFromRuntimeEnv(); + + // Fail closed: if this process is a proxy child (the injected `__VARLOCK_PROXY_CHILD` + // marker is the reliable in-tree signal) but its session record can't be resolved + // — missing, corrupt, or otherwise unreadable — we have no placeholder/omit overlay + // to apply, so resolving would re-expose REAL secrets. Refuse rather than leak. + // (The daemon and `proxy` command itself never carry this marker, so they're + // unaffected; only an actual proxied child is.) + if (process.env[PROXY_CHILD_ENV_VAR] === '1' && !(await getActiveProxySession())) { + throw new CliExitError('Proxy session record is unavailable', { + suggestion: 'The proxy session that launched this process can no longer be read ' + + '(it may have been stopped, or its record corrupted). Re-run inside an active ' + + '`varlock proxy run` / `varlock proxy start` session.', + }); + } + const proxyResolutionView = await getProxyResolutionViewForEnv().catch(() => undefined); if (proxyResolutionView) { debug('applying proxy resolution view (%d item(s))', Object.keys(proxyResolutionView).length); diff --git a/packages/varlock/src/lib/test/load-graph.test.ts b/packages/varlock/src/lib/test/load-graph.test.ts index 19e9ba297..98e58ba36 100644 --- a/packages/varlock/src/lib/test/load-graph.test.ts +++ b/packages/varlock/src/lib/test/load-graph.test.ts @@ -18,6 +18,7 @@ vi.mock('../../proxy/session-registry', () => ({ })); import { loadVarlockEnvGraph } from '../load-graph'; +import { PROXY_CHILD_ENV_VAR } from '../../proxy/env-vars'; describe('loadVarlockEnvGraph', () => { let tempDir: string; @@ -68,4 +69,33 @@ describe('loadVarlockEnvGraph', () => { expect(graph.getResolvedEnvObject().API_KEY).toBeUndefined(); }); + + describe('fail-closed when a proxy child cannot resolve its session', () => { + afterEach(() => { + delete process.env[PROXY_CHILD_ENV_VAR]; + }); + + test('refuses to load when the child marker is set but no session resolves', async () => { + // Marker present (in-tree proxy child) but the session record is gone / + // corrupt → no placeholder overlay. Resolving would re-expose the real + // secret, so loadVarlockEnvGraph must throw instead. + process.env[PROXY_CHILD_ENV_VAR] = '1'; + getActiveProxySessionMock.mockResolvedValue(undefined); + + await expect(loadVarlockEnvGraph({ entryFilePaths: [tempDir] })).rejects.toThrow(/session record is unavailable/i); + }); + + test('loads normally when the child marker is set and the session resolves', async () => { + process.env[PROXY_CHILD_ENV_VAR] = '1'; + getActiveProxySessionMock.mockResolvedValue({ id: 'abc', uuid: 'u' } as any); + getProxyResolutionViewForEnvMock.mockResolvedValue({ + API_KEY: { kind: 'placeholder', value: '<>' }, + }); + + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + expect(graph.getResolvedEnvObject().API_KEY).toBe('<>'); + }); + }); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index a22836fdb..a94fc5f0b 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -74,11 +74,20 @@ type HostInfo = { host: string, port: number }; type HeaderTransformFn = (value: string) => string; function parseHostPort(value: string): HostInfo | null { - const [host, portRaw] = value.split(':'); - if (!host) return null; - const port = Number(portRaw ?? 443); - if (Number.isNaN(port)) return null; - return { host, port }; + // Parse via URL so bracketed IPv6 literals (`[::1]:443`) are handled — a plain + // `split(':')` mangles them. The hostname comes back bracketed for IPv6; strip + // the brackets so the bare address flows to tls.connect / checkServerIdentity / + // the IP-SAN cert minting (all of which expect `::1`, not `[::1]`). + try { + const url = new URL(`http://${value}`); + const host = url.hostname.replace(/^\[|\]$/g, ''); + if (!host) return null; + const port = url.port ? Number(url.port) : 443; + if (Number.isNaN(port)) return null; + return { host, port }; + } catch { + return null; + } } function normalizeHost(host: string): string { @@ -101,25 +110,67 @@ function hostMatchesProxyRules(host: string, rules: Array): boolean { /** * Invariant #1: bind secret injection to the *verified upstream TLS identity*, - * not the requested name. Returns TLS options for the secret-bearing upstream - * request that (a) require validation against the public PKI - * (`rejectUnauthorized`) and (b) confirm the cert identity matches the host we - * dialed (which is the rule-matched host). On any mismatch the handshake fails, - * so the already-substituted request is never transmitted — a poisoned - * DNS/Host name causes a failed connection, never a leaked secret. + * not the requested name. Opens a TLS connection to the rule-matched host, proves + * the chain validates against the public PKI AND the cert identity matches that + * host, and returns the **verified peer IP**. The secret-bearing request is then + * pinned to that exact IP (see processProxiedRequest). + * + * Why not just rely on `https.request`'s own pre-write identity check? Some + * runtimes — notably Bun's `https.request`, which the compiled CLI binary runs on + * — flush the request (the `Authorization` header and body) to a wrong-identity + * upstream *before* `checkServerIdentity` rejects, leaking the secret. So we + * verify here, on a connection we control, and then pin the request to the proven + * IP. A poisoned DNS/Host name fails this verification (we abort, secret never + * sent); pinning the IP for the real request defeats a DNS-rebind between the two + * connections — the secret only ever reaches an address already proven to hold a + * valid cert for the rule host. */ -function buildVerifiedUpstreamOptions(): { - rejectUnauthorized: true; - checkServerIdentity: (servername: string, cert: tls.PeerCertificate) => Error | undefined; -} { - // Deliberately do NOT set `servername` — Node derives it from the request - // hostname (SNI for DNS names; omitted for IP literals, where setting it - // throws) and still passes the host to checkServerIdentity, so identity is - // verified against the host we dialed (= the rule-matched host) either way. - return { - rejectUnauthorized: true, - checkServerIdentity: (servername, cert) => tls.checkServerIdentity(servername, cert), - }; +function verifyUpstreamIdentity(host: string, port: number): Promise<{ address: string }> { + return new Promise((resolve, reject) => { + // SNI for DNS names; omitted for IP literals (setting `servername` to an IP + // throws). Identity is verified against `host` either way below. + const servername = net.isIP(host) ? undefined : host; + const socket = tls.connect({ + host, + port, + ...(servername ? { servername } : {}), + rejectUnauthorized: true, + ALPNProtocols: ['http/1.1'], + // Default trust store (system roots + NODE_EXTRA_CA_CERTS), but also honor + // any process-global CAs the user configured on the https agent (e.g. a + // corporate root) so we trust the same upstreams the rest of their stack + // does. Undefined in the common case → default roots. + ca: https.globalAgent.options.ca, + }); + const fail = (err: Error) => { + socket.destroy(); + reject(err); + }; + socket.once('error', fail); + socket.once('secureConnect', () => { + socket.removeListener('error', fail); + // (a) public-PKI chain must validate + if (!socket.authorized) { + fail(socket.authorizationError ?? new Error('upstream TLS chain not authorized')); + return; + } + // (b) cert identity must match the host we dialed (= the rule-matched host). + // checkServerIdentity handles both DNS names (dNSName SANs) and IP literals + // (iPAddress SANs) when given the host. + const identityError = tls.checkServerIdentity(host, socket.getPeerCertificate()); + if (identityError) { + fail(identityError); + return; + } + const address = socket.remoteAddress; + socket.destroy(); + if (!address) { + reject(new Error('verified upstream has no remote address')); + return; + } + resolve({ address }); + }); + }); } /** @@ -633,15 +684,52 @@ export async function startLocalProxyRuntime({ upstreamHeaders['content-length'] = String(rewrittenBody.byteLength); } + const upstreamPort = t.port || (t.isHttps ? 443 : 80); + + // Invariant #1: for TLS upstreams, verify the identity on a connection we + // control BEFORE writing any secret, then pin the request to the proven IP. + // We can't reuse the verified socket directly (Bun's https client won't accept + // a handed-in socket), so we pin by IP — the secret only ever reaches an + // address already proven to hold a valid cert for the rule host, defeating + // DNS-poison/rebind. Cleartext (http) upstreams never carry an injected secret + // — the cleartext guard above fails closed when hostItems.length > 0 && !isHttps. + let verifiedAddress: string | undefined; + if (t.isHttps) { + try { + ({ address: verifiedAddress } = await verifyUpstreamIdentity(t.host, upstreamPort)); + } catch { + // Fail closed: the upstream identity could not be verified, so the secret + // was never transmitted. + respondBlocked(res, 502, 'Upstream request failed', t.tunnelTeardown); + return; + } + } + + // For DNS-name hosts, send SNI for (and re-check identity against) the rule + // host even though we dial the pinned IP. For IP-literal hosts there is no SNI. + const sni = t.isHttps && !net.isIP(t.host) ? t.host : undefined; const agent = t.isHttps ? https : http; const upstreamReq = agent.request({ protocol: t.isHttps ? 'https:' : 'http:', - hostname: t.host, - port: t.port || (t.isHttps ? 443 : 80), + // Pin to the verified peer IP (https) so the request can't be re-resolved to + // a different host between verification and send. + hostname: verifiedAddress ?? t.host, + port: upstreamPort, method: req.method, path: rewrittenPath, headers: upstreamHeaders, - ...(t.isHttps ? buildVerifiedUpstreamOptions() : {}), + ...(t.isHttps + ? { + ...(sni ? { servername: sni } : {}), + rejectUnauthorized: true, + // Defense-in-depth: re-check the cert identity against the rule host + // (not the pinned IP we dialed). Redundant given the pinned-IP proof, + // but cheap. (Some runtimes ignore this; the pinned-IP proof is the + // real guarantee — see verifyUpstreamIdentity.) + checkServerIdentity: (_sni: string, cert: tls.PeerCertificate) => tls.checkServerIdentity(t.host, cert), + ca: https.globalAgent.options.ca, + } + : {}), }, (upstreamRes) => { forwardUpstreamResponseWithRedaction(upstreamRes, res, hostItems, shouldRewrite, { host: t.host, diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 8fe98b419..7148ca81f 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -1,9 +1,9 @@ import crypto from 'node:crypto'; import { existsSync } from 'node:fs'; import { - mkdir, readdir, readFile, rm, writeFile, + mkdir, readdir, readFile, rename, writeFile, } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { getUserVarlockDir } from '../lib/user-config-dir'; import { getAncestorPids } from './process-ancestry'; @@ -84,6 +84,21 @@ function getSessionFilePath(uuid: string): string { return join(getProxySessionDir(uuid), 'session.json'); } +/** + * Write a session record atomically (tmp + rename) so a concurrent reader never + * sees a half-written file. The daemon rewrites session.json on a debounce as + * traffic flows, and any varlock invocation reads it — a bare truncate-then-write + * would expose torn JSON, which `parseSessionRecord` must never delete (doing so + * would silently drop the live record and let a proxied child re-resolve real + * secrets). The `.tmp` suffix isn't `session.json`, so `listProxySessions` ignores it. + */ +async function writeSessionRecordAtomic(filePath: string, record: ProxySessionRecord): Promise { + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); + const tmp = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`; + await writeFile(tmp, JSON.stringify(record, null, 2), { mode: 0o600 }); + await rename(tmp, filePath); +} + function nowIso(): string { return new Date().toISOString(); } @@ -98,7 +113,7 @@ function randomShortId(length = 5): string { return out; } -function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | undefined { +function parseSessionRecord(raw: string): ProxySessionRecord | undefined { try { const parsed = JSON.parse(raw) as Partial; if (!parsed || typeof parsed !== 'object') return undefined; @@ -154,7 +169,13 @@ function parseSessionRecord(raw: string, filePath: string): ProxySessionRecord | : {}), }; } catch { - rm(filePath, { force: true }).catch(() => undefined); + // Skip an unparseable record — never delete it. Writes are atomic (tmp + + // rename), so a reader sees either the old or the new complete file; a parse + // failure therefore means genuine corruption, not a torn read. Deleting here + // would be far worse than leaving it: dropping a still-live record removes the + // proxy's placeholder/omit overlay and lets a proxied child re-resolve REAL + // secrets. A genuinely corrupt orphan just lingers (a rare, harmless disk + // leak) rather than risking a secret leak. return undefined; } } @@ -190,7 +211,7 @@ export async function listProxySessions(opts?: { const raw = await readFile(filePath, 'utf8').catch(() => undefined); if (!raw) continue; - const parsed = parseSessionRecord(raw, filePath); + const parsed = parseSessionRecord(raw); if (!parsed) continue; if (!opts?.includeEnded) { @@ -214,11 +235,11 @@ export async function markProxySessionEnded(uuid: string) { const filePath = getSessionFilePath(uuid); const raw = await readFile(filePath, 'utf8').catch(() => undefined); if (!raw) return; - const existing = parseSessionRecord(raw, filePath); + const existing = parseSessionRecord(raw); if (!existing || existing.endedAt) return; const ts = nowIso(); const next: ProxySessionRecord = { ...existing, endedAt: ts, updatedAt: ts }; - await writeFile(filePath, JSON.stringify(next, null, 2), { mode: 0o600 }); + await writeSessionRecordAtomic(filePath, next); } /** @@ -250,7 +271,7 @@ export async function createProxySessionRecord(session: Omit Date: Thu, 25 Jun 2026 23:04:21 -0700 Subject: [PATCH 33/64] docs(proxy): remove approval from docs, fix headline example, sharpen framing - Pull the approval section/options from the guide and reference (the code stays, it's just undocumented while it's reworked). - Headline + quick-start examples use a valid `@placeholder` so they don't trip the generic-placeholder warning (and the OpenAI SDK key-format check). - Note that varlock treats items as sensitive by default (mark @public to pass non-secret config through); soften the "can't recover by re-running varlock" claim and add the out-of-tree / pair-with-a-sandbox caveat. - Rewrite the schema-editing section for opt-in reload (restart by default); document `--allow-reload` and the default-egress-isn't-containment limitation. --- .../src/content/docs/guides/proxy.mdx | 77 ++++++++----------- .../content/docs/reference/cli-commands.mdx | 9 ++- .../docs/reference/item-decorators.mdx | 3 +- .../docs/reference/root-decorators.mdx | 4 +- 4 files changed, 42 insertions(+), 51 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 9d3343b1a..d2fbb9fbf 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -15,6 +15,7 @@ The credential proxy is an early preview. The core protection (placeholder isola # @enableProxy(egress="permissive") # --- # @proxy(domain="api.openai.com") +# @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=sk-... ``` @@ -22,11 +23,9 @@ OPENAI_API_KEY=sk-... varlock proxy run -- claude ``` -The agent launched above sees `OPENAI_API_KEY=vlk_placeholder_…` (or a format-shaped placeholder). When it makes a request to `api.openai.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. +The agent launched above sees `OPENAI_API_KEY=sk-proj-0000…` — a placeholder shaped like a real key. When it makes a request to `api.openai.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. -:::note[Approvals are not part of this release] -The proxy supports an experimental, interactive **approval** step (`approval=true`), but it is TTY-only and still being built out. The core protection described in this guide — placeholder isolation, wire-level injection, egress control, and auditing — does not depend on it. See [Approvals (experimental)](#approvals-experimental) at the end. -::: +The `@placeholder` keeps the placeholder valid-looking so the OpenAI SDK's client-side key-format check passes. See [Placeholders](#placeholders) for when you need it. ## How it works @@ -39,6 +38,8 @@ Secrets and the proxy's signing key live only in memory on your machine — they ## Quick start +This assumes you already have varlock [installed](/getting-started/installation/) and a working `.env.schema` whose secrets resolve (committed encrypted values, a [plugin](/plugins/overview/), or env values you can load). + #### 1. Enable the proxy Add the [`@enableProxy`](/reference/root-decorators/#enableproxy) root decorator to your schema header: @@ -56,13 +57,22 @@ Add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** yo # @enableProxy(egress="permissive") # --- # @proxy(domain="api.openai.com") +# @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=sk-... # @proxy(domain="api.stripe.com") STRIPE_SECRET_KEY=sk_live_... ``` -#### 3. Run your agent through it +The `@placeholder` on `OPENAI_API_KEY` makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like `STRIPE_SECRET_KEY` above) and the item gets a generic `vlk_placeholder_…`, which varlock warns about because it can fail a client-side key-format check (see [Placeholders](#placeholders)). + +#### 3. Check your config (optional) + +```bash +varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started +``` + +#### 4. Run your agent through it ```bash varlock proxy run -- claude @@ -74,7 +84,7 @@ varlock proxy run -- python tool.py That's it — the child inherits everything it needs (proxy address + CA trust) automatically. :::caution[Put `@proxy` on the item, not in the header] -`@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block`/`approval` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected — it won't be. See [Routing rules](#routing-rules). +`@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected — it won't be. See [Routing rules](#routing-rules). ::: ## Routing rules @@ -104,7 +114,7 @@ STRIPE_SECRET_KEY=sk_live_... ### Attached vs detached rules - **Attached rule** — `@proxy` on an item. Injects that item's secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) -- **Detached rule** — `@proxy` in the header. A policy-only rule (match, `block`, or `approval`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. +- **Detached rule** — `@proxy` in the header. A policy-only rule (match or `block`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. ### Egress modes @@ -121,7 +131,11 @@ By default, varlock applies **least privilege** to the proxied child: - A [`@sensitive`](/reference/item-decorators/#sensitive) item with **no** proxy policy → the agent sees a **placeholder** too (it just isn't injected anywhere). The real value never reaches the child. - Non-sensitive items → passed through normally. -Because every sensitive item resolves to a placeholder inside a proxied session, an agent can't recover a secret by re-running `varlock load` / `varlock printenv` against the schema — it gets the same placeholder back, not the real value. +:::note[varlock treats items as sensitive by default] +Unless an item is marked [`@public`](/reference/item-decorators/#sensitive) (or made non-sensitive by a [`@type`](/reference/item-decorators/#type) / `@defaultSensitive` rule), varlock considers it sensitive — so it becomes a **placeholder** in the proxied child. If your agent needs to read a non-secret config value (an API base URL, a feature flag), mark it `@public` so it passes through with its real value. +::: + +Because every sensitive item resolves to a placeholder inside a proxied session, an agent can't **trivially** recover a secret by re-running `varlock load` / `varlock printenv` from within the proxied session — it gets the same placeholder back, not the real value. (A determined agent on the same machine can still escape this — see [Limitations](#limitations); pair the proxy with a sandbox for a real boundary.) To override the default for an item, use the value form of `@proxy`: @@ -165,19 +179,19 @@ There are three ways to drive the proxy, trading convenience for interactivity. varlock proxy run -- ``` -Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a `proxy start` daemon is already running for this directory, `proxy run` **attaches** to it (so its terminal can handle approval prompts); otherwise it runs a self-contained proxy. Pass `--new` to force a fresh, separate proxy. +Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a `proxy start` daemon is already running for this directory, `proxy run` **attaches** to it (so its terminal owns the live request log and any prompts); otherwise it runs a self-contained proxy. Pass `--new` to force a fresh, separate proxy. ### Daemon + attach: `proxy start` ```bash -# Terminal 1 — owns the proxy and any approval prompts +# Terminal 1 — owns the proxy and its live request log varlock proxy start # Terminal 2 — attaches automatically varlock proxy run -- claude ``` -`proxy start` is a long-lived session that owns its terminal. This is the mode to use if you want interactive [approvals](#approvals-experimental), since the prompt appears in the daemon's terminal rather than competing with the agent's stdio. +`proxy start` is a long-lived session that owns its terminal, where the live per-request log appears. Attach to it from another terminal (or shell) so the agent's stdio doesn't compete with the daemon's output. ### Manual env: `proxy start` + `proxy env` @@ -222,51 +236,28 @@ varlock proxy status --watch # live updates ## Editing the schema while a session is running -For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started (this prevents an agent from editing the schema to downgrade `@sensitive` and recover real values). After an intentional edit, re-approve it from a **trusted (non-proxied) shell**: +For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started. This prevents an agent from editing the schema mid-session — downgrading `@sensitive`, or adding a new item that resolves a secret — to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: ```bash -varlock proxy refresh --session +varlock proxy stop --all +varlock proxy start # or: varlock proxy run -- ``` -For a `proxy start` daemon this **hot-reloads** the running proxy — it re-resolves your schema in the daemon's trusted context and swaps the live policy (rules, injected secrets, egress mode) without dropping the proxy or restarting your agent. The daemon's terminal prints a confirmation. (A one-shot `proxy run` isn't reloadable — it already re-reads the schema on its next invocation.) - -## Approvals (experimental) +A one-shot `varlock proxy run` already re-reads the schema on its next invocation, so just re-run it. -:::caution[Experimental — TTY only] -Approvals work today only via an interactive `varlock proxy start` terminal. There is no phone/native approver yet, and running an approval-gated schema through a non-interactive `proxy run` (with no daemon to attach to) will **deny** the held requests. Treat this as a preview. +:::note[Hot-reload is opt-in] +You can start a daemon with `varlock proxy start --allow-reload` to enable `varlock proxy refresh`, which re-resolves the schema and swaps the live policy without restarting. It's **off by default**: the reload channel is unauthenticated on a shared uid, so a same-uid agent could trigger a refresh to self-approve its own schema edit. Only enable it behind a sandbox (or once a real out-of-band approver ships). ::: -A rule can require explicit, out-of-band approval before each matching request is forwarded: - -```env-spec title=".env.schema" -# @enableProxy(egress="strict") -# @proxy(domain="api.stripe.com", path="/v1/refunds/**", approval=true) -# --- -# @proxy(domain="api.stripe.com") -STRIPE_SECRET_KEY=sk_live_... -``` - -When matched under `proxy start`, the request is held and you're prompted to approve it (once, for the session, or for a time window). For finer control, pass `approval` as an options object instead of `approval=true`: - -```env-spec title=".env.schema" -# @proxy(domain="api.stripe.com", path="/v1/refunds/**", approval={each=request, maxDuration="15m"}) -``` - -- `each` — what one approval covers: `host`, `endpoint` (method + path, the default), or `request` (also binds the body). -- `maxDuration` — a ceiling on how long a "yes" is remembered: a duration like `"15m"`, `0` for always-ask, or unset for the whole session. The ceiling is enforced by the proxy, not just the prompt. -- `enabled` — set `enabled=false` to turn the rule into a plain allow (useful for dynamically toggling approval, e.g. `enabled=forEnv(production)`). - -The object form implies approval is required; `approval=true` is shorthand for the defaults. - ## Limitations The proxy is a local, same-machine tool. Know what it does and doesn't cover before relying on it: -- **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *reading* a secret it's *using*, but it is **not a sandbox** — it doesn't isolate the filesystem, other processes, or memory. Pair it with an OS sandbox/container if you need stronger isolation. +- **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *trivially reading* a secret it's *using*, but it is **not a sandbox** — it doesn't isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy's view (e.g. reparented via `setsid`) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Pair the proxy with an OS sandbox/container for real isolation. - **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed** — the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection — the agent only ever holds a placeholder — is unaffected. - **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream — by design. -- **Live policy reload is unauthenticated.** Any process running as your user can trigger a `proxy refresh`. On a shared-uid machine this is not a trust boundary; a real out-of-band approver is planned to close it. -- **Approvals are a TTY-only preview.** See [Approvals (experimental)](#approvals-experimental). +- **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect — it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. +- **Hot-reload is unauthenticated (and off by default).** `proxy refresh` only works when the daemon was started with `--allow-reload`. Even then, any process running as your user can trigger it, so on a shared uid it is not a trust boundary; a real out-of-band approver is planned to close it. - **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely — it just won't get a working secret. ## Reference diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index a2b9443e2..7432ed3ab 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -647,18 +647,19 @@ varlock proxy [options] **Subcommands:** - `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. -- `start`: Start a long-lived proxy session that owns the terminal (interactive approval prompts and a live request log appear here). Stop with `Ctrl+C`. -- `rules`: Print a static summary of the effective `@proxy` configuration — the rules (host/path/method, block/approval) and each secret's mode (proxied / placeholder / passthrough / omit) — without starting a proxy. +- `start`: Start a long-lived proxy session that owns the terminal (a live request log appears here). Stop with `Ctrl+C`. +- `rules`: Print a static summary of the effective `@proxy` configuration — the rules (host/path/method, block) and each secret's mode (proxied / placeholder / passthrough / omit) — without starting a proxy. - `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). - `status`: List active proxy sessions. - `audit`: Print a session's request audit log (no secret values). -- `refresh`: Hot-reload a running `proxy start` daemon after an intentional schema edit — re-resolves and swaps the live policy (rules, injected secrets, egress) without restarting. +- `refresh`: Hot-reload a daemon started with `--allow-reload` after an intentional schema edit — re-resolves and swaps the live policy without restarting. (Off by default; otherwise restart the proxy to apply schema changes.) - `stop`: Stop a session. **Options:** - `--session `: Target a specific session by id. - `--new`: For `run`, force a fresh proxy instead of attaching to a running one. - `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). +- `--allow-reload`: For `start`/`run`, enable live policy hot-reload via `proxy refresh` (off by default — the reload channel is unauthenticated on a shared uid; only enable behind a sandbox). - `--inject `: For `run`, control what is injected into the child env (default `all`). - `--no-redact-stdout`: For `run`, disable stdout/stderr redaction (full TTY pass-through). - `--watch`: For `status`, continuously refresh. @@ -669,7 +670,7 @@ varlock proxy [options] # Run an agent through the proxy varlock proxy run -- claude -# Daemon in one terminal, attach from another (enables interactive approvals) +# Daemon in one terminal, attach from another varlock proxy start varlock proxy run -- node agent.js diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 4c018202f..0280c63df 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,7 +256,7 @@ OPENAI_API_KEY=sk-proj-... 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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). -**Function form** — `@proxy(domain=..., [path], [method], [block], [keys], [approval])`: +**Function form** — `@proxy(domain=..., [path], [method], [block], [keys])`: | Option | Meaning | |---|---| @@ -265,7 +265,6 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | | `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. | -| `approval` _(experimental)_ | `approval=true` holds matching requests for interactive approval. Pass an options object for finer control: `approval={each=request, maxDuration="15m"}` — `each` (`host`|`endpoint`|`request`) is what one approval covers, `maxDuration` caps how long a "yes" is remembered (`"15m"`, `0` = always ask), and `enabled=false` makes it a plain allow. The object form implies approval is required. | The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`). diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index efb896a44..ba0a38465 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -507,9 +507,9 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow.
### `@proxy()` -**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], approval?, ...)` +**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], ...)` -Defines a **detached** [credential proxy](/guides/proxy/) rule — a domain-level policy (match, `block`, or `approval`). It injects no secret on its own, but can inject named items via `keys=[...]`. Requires [`@enableProxy`](#enableproxy). +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=[...]`. Requires [`@enableProxy`](#enableproxy). 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). From 0cc84396141864ce3d40bf1493bee28f1256079d Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 25 Jun 2026 23:04:22 -0700 Subject: [PATCH 34/64] ci: run the proxy verified-TLS invariant check under Bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitest suite runs under Node; this adds a CI step running the proxy's verified-identity injection (Invariant #1) under Bun — the runtime the compiled binary uses — which Node cannot exercise (the leak it guards against is Bun-specific). --- .github/workflows/test.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e40e80c5..fc17ad34d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -54,6 +54,11 @@ jobs: run: bun run build:libs - name: Run tests run: bun run test:ci + # The vitest suite runs under Node; this check runs the proxy's verified-TLS + # injection (Invariant #1) under Bun, the runtime the compiled binary uses — + # Node cannot catch the Bun-specific pre-write leak it guards against. + - name: Proxy verified-TLS injection (Bun) + run: bun run --filter varlock test:proxy:bun # Determine which packages will be preview-released (used to gate native builds) - name: Check release packages From b65973f13677ec675aa8fad32290799ee32f5e9b Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 10:30:54 -0700 Subject: [PATCH 35/64] docs(proxy): drop em dashes; add a no-em-dash convention to AGENTS.md Rewrite the proxy guide and the proxy sections of the CLI / item-decorator / root-decorator references to use colons, commas, semicolons, or parentheses instead of em dashes. Add a Writing style rule to AGENTS.md so agents avoid em/en dashes in prose going forward. --- AGENTS.md | 6 +- .../src/content/docs/guides/proxy.mdx | 56 +++++++++---------- .../content/docs/reference/cli-commands.mdx | 8 +-- .../docs/reference/item-decorators.mdx | 6 +- .../docs/reference/root-decorators.mdx | 8 +-- 5 files changed, 44 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bebbeebec..8768dfb6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,5 +62,9 @@ This is a monorepo managed with bun workspaces and Turborepo: ## Linting - Run **`bun run lint:fix`** from the repo root after completing a significant chunk of work (new feature, refactor, bug fix, etc.) -- The linter uses ESLint with `@stylistic` and other plugins — auto-fix handles most formatting issues +- The linter uses ESLint with `@stylistic` and other plugins; auto-fix handles most formatting issues - Do not leave lint errors unresolved; fix any that `--fix` cannot handle automatically + +## Writing style + +- **Do not use em dashes (`—`) or en dashes (`–`)** in any prose you write: docs, code comments, commit messages, PR descriptions, changeset entries, or design notes. They read as an AI-writing tell. Rewrite with a colon, comma, semicolon, parentheses, or two sentences instead. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index d2fbb9fbf..a2565f586 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -3,9 +3,9 @@ title: Credential proxy description: Run AI agents and untrusted tools through a local proxy so they only ever see placeholder secrets, while real values are injected at the network boundary. --- -The credential proxy lets you run an AI agent (or any untrusted tool) so that it **never sees your real secrets** — it only ever has placeholders in its environment. The real values are injected into outbound requests at the network boundary, against a cryptographically verified upstream, and every request is logged. +The credential proxy lets you run an AI agent (or any untrusted tool) so that it **never sees your real secrets**: it only ever has placeholders in its environment. The real values are injected into outbound requests at the network boundary, against a cryptographically verified upstream, and every request is logged. -This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it — coding agents, MCP servers, third-party CLIs, or scripts. +This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it: coding agents, MCP servers, third-party CLIs, or scripts. :::caution[Preview] The credential proxy is an early preview. The core protection (placeholder isolation + verified-identity wire injection) is solid, but there are real limits to what it covers and it runs on the same machine/user as the agent. Read [Limitations](#limitations) before relying on it as a security boundary. @@ -23,7 +23,7 @@ OPENAI_API_KEY=sk-... varlock proxy run -- claude ``` -The agent launched above sees `OPENAI_API_KEY=sk-proj-0000…` — a placeholder shaped like a real key. When it makes a request to `api.openai.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. +The agent launched above sees `OPENAI_API_KEY=sk-proj-0000…`, a placeholder shaped like a real key. When it makes a request to `api.openai.com`, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder. The `@placeholder` keeps the placeholder valid-looking so the OpenAI SDK's client-side key-format check passes. See [Placeholders](#placeholders) for when you need it. @@ -34,7 +34,7 @@ The `@placeholder` keeps the placeholder valid-looking so the OpenAI SDK's clien 3. The proxy intercepts HTTPS traffic. For a request that matches a rule, it verifies the upstream's real TLS identity, then substitutes the placeholder for the real secret **only on that connection**. 4. Responses are scrubbed so real values can't leak back into the child's output, and every request is recorded to an [audit log](#auditing). -Secrets and the proxy's signing key live only in memory on your machine — they are never written to disk and never handed to the child. +Secrets and the proxy's signing key live only in memory on your machine; they are never written to disk and never handed to the child. ## Quick start @@ -81,10 +81,10 @@ varlock proxy run -- node agent.js varlock proxy run -- python tool.py ``` -That's it — the child inherits everything it needs (proxy address + CA trust) automatically. +That's it. The child inherits everything it needs (proxy address + CA trust) automatically. :::caution[Put `@proxy` on the item, not in the header] -`@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected — it won't be. See [Routing rules](#routing-rules). +`@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected, but it won't be. See [Routing rules](#routing-rules). ::: ## Routing rules @@ -93,7 +93,7 @@ A `@proxy(...)` rule supports more than just a domain: | Option | Meaning | |---|---| -| `domain` | **(required)** Host to match — a single host or an array list. Supports globs, e.g. `*.example.com`. | +| `domain` | **(required)** Host to match: a single host or an array list. Supports globs, e.g. `*.example.com`. | | `path` | Restrict to matching URL paths (glob), e.g. `path="/v1/**"`. | | `method` | Restrict to one or more HTTP methods, e.g. `method=[GET, POST]`. | | `block` | `block=true` denies matching requests outright (fail closed). | @@ -103,7 +103,7 @@ A `@proxy(...)` rule supports more than just a domain: ```env-spec title=".env.schema" # @enableProxy(egress="strict") -# Block a dangerous endpoint entirely (detached rule — no injection): +# Block a dangerous endpoint entirely (detached rule, no injection): # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # --- # Match either host, any method: @@ -113,15 +113,15 @@ STRIPE_SECRET_KEY=sk_live_... ### Attached vs detached rules -- **Attached rule** — `@proxy` on an item. Injects that item's secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) -- **Detached rule** — `@proxy` in the header. A policy-only rule (match or `block`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. +- **Attached rule**: `@proxy` on an item. Injects that item's secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) +- **Detached rule**: `@proxy` in the header. A policy-only rule (match or `block`) for a domain. It injects nothing on its own, but can inject named items with `keys=[...]`. ### Egress modes `@enableProxy(egress=...)` controls what happens to requests that **don't** match any rule: -- `permissive` _(default)_ — unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. -- `strict` — only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. +- `permissive` _(default)_: unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. +- `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. ## Controlling what the agent sees @@ -132,10 +132,10 @@ By default, varlock applies **least privilege** to the proxied child: - Non-sensitive items → passed through normally. :::note[varlock treats items as sensitive by default] -Unless an item is marked [`@public`](/reference/item-decorators/#sensitive) (or made non-sensitive by a [`@type`](/reference/item-decorators/#type) / `@defaultSensitive` rule), varlock considers it sensitive — so it becomes a **placeholder** in the proxied child. If your agent needs to read a non-secret config value (an API base URL, a feature flag), mark it `@public` so it passes through with its real value. +Unless an item is marked [`@public`](/reference/item-decorators/#sensitive) (or made non-sensitive by a [`@type`](/reference/item-decorators/#type) / `@defaultSensitive` rule), varlock considers it sensitive, so it becomes a **placeholder** in the proxied child. If your agent needs to read a non-secret config value (an API base URL, a feature flag), mark it `@public` so it passes through with its real value. ::: -Because every sensitive item resolves to a placeholder inside a proxied session, an agent can't **trivially** recover a secret by re-running `varlock load` / `varlock printenv` from within the proxied session — it gets the same placeholder back, not the real value. (A determined agent on the same machine can still escape this — see [Limitations](#limitations); pair the proxy with a sandbox for a real boundary.) +Because every sensitive item resolves to a placeholder inside a proxied session, an agent can't **trivially** recover a secret by re-running `varlock load` / `varlock printenv` from within the proxied session; it gets the same placeholder back, not the real value. (A determined agent on the same machine can still escape this; see [Limitations](#limitations) and pair the proxy with a sandbox for a real boundary.) To override the default for an item, use the value form of `@proxy`: @@ -144,7 +144,7 @@ To override the default for an item, use the value form of `@proxy`: # @proxy=passthrough # inject the REAL value into the child (escape hatch) LEGACY_TOKEN=... -# @proxy=omit # withhold entirely — absent from the child env, and +# @proxy=omit # withhold entirely: absent from the child env, and # resolves to "unset" (not the real value) if re-resolved UNUSED_SECRET=... ``` @@ -155,9 +155,9 @@ UNUSED_SECRET=... The placeholder the agent sees is chosen in priority order: -1. An explicit [`@placeholder`](/reference/item-decorators/#placeholder) value — always wins. -2. A valid-and-unique value derived from the item's [`@type`](/reference/item-decorators/#type) — e.g. `@type=url` → `https://vlk-placeholder-…invalid/`, `@type=email` / `uuid` / `md5` likewise, and `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. -3. A generic fallback (`vlk_placeholder__…`) — for a `@proxy`-routed (wire-injected) item varlock **warns** about these, because a generic placeholder can fail an SDK's client-side key-format check (e.g. an `sk-…` prefix assertion) before the request is ever made. +1. An explicit [`@placeholder`](/reference/item-decorators/#placeholder) value (always wins). +2. A valid-and-unique value derived from the item's [`@type`](/reference/item-decorators/#type): e.g. `@type=url` → `https://vlk-placeholder-…invalid/`, `@type=email` / `uuid` / `md5` likewise, and `@type=string(startsWith=sk-, isLength=20)` yields an `sk-`-shaped placeholder. +3. A generic fallback (`vlk_placeholder__…`): for a `@proxy`-routed (wire-injected) item varlock **warns** about these, because a generic placeholder can fail an SDK's client-side key-format check (e.g. an `sk-…` prefix assertion) before the request is ever made. Every placeholder is unique per item, so two different secrets can never collide on the wire. @@ -184,10 +184,10 @@ Starts a proxy, runs the command through it, and tears down when the command exi ### Daemon + attach: `proxy start` ```bash -# Terminal 1 — owns the proxy and its live request log +# Terminal 1: owns the proxy and its live request log varlock proxy start -# Terminal 2 — attaches automatically +# Terminal 2: attaches automatically varlock proxy run -- claude ``` @@ -213,12 +213,12 @@ node agent.js # now routed through the proxy The proxy uses an **ephemeral, in-memory certificate authority** generated per run; only its public certificate is written to a temp file for the child to trust. The CA private key and all per-host certificates never leave memory. :::note[Clients that don't read these env vars] -Tools that ignore the standard proxy/CA environment variables — many GUI apps, browsers, and some language runtimes with their own trust stores — won't be transparently proxied. The target here is CLI tools and agents, which generally do respect them. +Tools that ignore the standard proxy/CA environment variables (many GUI apps, browsers, and some language runtimes with their own trust stores) won't be transparently proxied. The target here is CLI tools and agents, which generally do respect them. ::: ## 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). ```bash varlock proxy audit # current/most-recent session @@ -236,7 +236,7 @@ varlock proxy status --watch # live updates ## Editing the schema while a session is running -For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started. This prevents an agent from editing the schema mid-session — downgrading `@sensitive`, or adding a new item that resolves a secret — to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: +For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started. This prevents an agent from editing the schema mid-session (downgrading `@sensitive`, or adding a new item that resolves a secret) to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: ```bash varlock proxy stop --all @@ -253,12 +253,12 @@ You can start a daemon with `varlock proxy start --allow-reload` to enable `varl The proxy is a local, same-machine tool. Know what it does and doesn't cover before relying on it: -- **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *trivially reading* a secret it's *using*, but it is **not a sandbox** — it doesn't isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy's view (e.g. reparented via `setsid`) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Pair the proxy with an OS sandbox/container for real isolation. -- **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed** — the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection — the agent only ever holds a placeholder — is unaffected. -- **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream — by design. -- **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect — it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. +- **Same host, same user.** The proxy runs as you, and so does the agent. It stops the agent from *trivially reading* a secret it's *using*, but it is **not a sandbox**: it doesn't isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy's view (e.g. reparented via `setsid`) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Pair the proxy with an OS sandbox/container for real isolation. +- **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed**: the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected. +- **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream, by design. +- **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. - **Hot-reload is unauthenticated (and off by default).** `proxy refresh` only works when the daemon was started with `--allow-reload`. Even then, any process running as your user can trigger it, so on a shared uid it is not a trust boundary; a real out-of-band approver is planned to close it. -- **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely — it just won't get a working secret. +- **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won't get a working secret. ## Reference diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index 7432ed3ab..c10af901c 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -639,7 +639,7 @@ You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` env
### `varlock proxy` ||proxy|| -Manages [credential proxy](/guides/proxy/) sessions — running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in your schema. +Manages [credential proxy](/guides/proxy/) sessions: running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in your schema. ```bash varlock proxy [options] @@ -648,18 +648,18 @@ varlock proxy [options] **Subcommands:** - `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. - `start`: Start a long-lived proxy session that owns the terminal (a live request log appears here). Stop with `Ctrl+C`. -- `rules`: Print a static summary of the effective `@proxy` configuration — the rules (host/path/method, block) and each secret's mode (proxied / placeholder / passthrough / omit) — without starting a proxy. +- `rules`: Print a static summary of the effective `@proxy` configuration: the rules (host/path/method, block) and each secret's mode (proxied / placeholder / passthrough / omit), without starting a proxy. - `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). - `status`: List active proxy sessions. - `audit`: Print a session's request audit log (no secret values). -- `refresh`: Hot-reload a daemon started with `--allow-reload` after an intentional schema edit — re-resolves and swaps the live policy without restarting. (Off by default; otherwise restart the proxy to apply schema changes.) +- `refresh`: Hot-reload a daemon started with `--allow-reload` after an intentional schema edit; re-resolves and swaps the live policy without restarting. (Off by default; otherwise restart the proxy to apply schema changes.) - `stop`: Stop a session. **Options:** - `--session `: Target a specific session by id. - `--new`: For `run`, force a fresh proxy instead of attaching to a running one. - `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). -- `--allow-reload`: For `start`/`run`, enable live policy hot-reload via `proxy refresh` (off by default — the reload channel is unauthenticated on a shared uid; only enable behind a sandbox). +- `--allow-reload`: For `start`/`run`, enable live policy hot-reload via `proxy refresh` (off by default; the reload channel is unauthenticated on a shared uid, so only enable behind a sandbox). - `--inject `: For `run`, control what is injected into the child env (default `all`). - `--no-redact-stdout`: For `run`, disable stdout/stderr redaction (full TTY pass-through). - `--watch`: For `status`, continuously refresh. diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 0280c63df..ef3d4dd70 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,11 +256,11 @@ OPENAI_API_KEY=sk-proj-... 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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). -**Function form** — `@proxy(domain=..., [path], [method], [block], [keys])`: +**Function form** `@proxy(domain=..., [path], [method], [block], [keys])`: | Option | Meaning | |---|---| -| `domain` | **(required)** Host to match — a single host or an array (`[a.com, b.com]`); supports globs (`*.example.com`). | +| `domain` | **(required)** Host to match: a single host or an array (`[a.com, b.com]`); supports globs (`*.example.com`). | | `path` | Restrict to matching URL paths (glob), e.g. `"/v1/**"`. | | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | @@ -268,7 +268,7 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`). -**Value form** — `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. +**Value form** `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. ```env-spec # @enableProxy(egress="strict") diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index ba0a38465..5bcc05d2e 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -492,8 +492,8 @@ API_KEY= Enables the [credential proxy](/guides/proxy/), which lets you run an untrusted child process (e.g. an AI agent) that only ever sees placeholder secrets while real values are injected at the network boundary. The `egress` option controls requests that don't match any [`@proxy`](#proxy) rule: -- `permissive` _(default)_ — unmatched hosts pass through untouched. -- `strict` — only requests matching a `@proxy` rule are allowed; everything else is blocked. +- `permissive` _(default)_: unmatched hosts pass through untouched. +- `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. ```env-spec # @enableProxy(egress="strict") @@ -509,9 +509,9 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow. ### `@proxy()` **Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], ...)` -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=[...]`. Requires [`@enableProxy`](#enableproxy). +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=[...]`. Requires [`@enableProxy`](#enableproxy). -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). +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). ```env-spec # @enableProxy(egress="permissive") From 0c18861085655d052ea32c55d6b2e678c1eafbd9 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 11:52:02 -0700 Subject: [PATCH 36/64] feat(proxy): group policies under one domain via @proxy(domain=x, rules=[...]) Adds a rules=[{path, method, block, approval}, ...] array to @proxy so a host with several path/method policies is written once. The parent @proxy still controls injection; each entry is a policy-only refinement that inherits the domain and injects nothing, desugaring into the existing flat rule list (the runtime and all security logic are unchanged). Entries reject domain/keys and unknown options. Also reject unknown top-level @proxy options at resolve time, closing a gap where a header-rule typo (e.g. blok=true) was silently dropped, turning a deny rule into a permissive allow. --- .bumpy/proxy-nested-rules.md | 7 ++ .../src/content/docs/guides/proxy.mdx | 17 ++++ .../docs/reference/item-decorators.mdx | 3 +- .../docs/reference/root-decorators.mdx | 2 +- .../cli/commands/test/proxy.command.test.ts | 52 ++++++++++++ .../varlock/src/env-graph/lib/decorators.ts | 38 ++++++++- .../varlock/src/env-graph/lib/env-graph.ts | 82 ++++++++++++++----- 7 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 .bumpy/proxy-nested-rules.md diff --git a/.bumpy/proxy-nested-rules.md b/.bumpy/proxy-nested-rules.md new file mode 100644 index 000000000..386c7c7f4 --- /dev/null +++ b/.bumpy/proxy-nested-rules.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`@proxy` supports a `rules=[{...}]` array to group several path/method policies under one domain. + +Instead of repeating `domain` on every rule, write it once and list the refinements: `@proxy(domain="api.stripe.com", rules=[{path="/v1/refunds/**", block=true}, {path="/v1/payouts/**", block=true}])`. The parent `@proxy(...)` still controls injection; each entry is a policy-only refinement (it may set `path`/`method`/`block`/`approval`, inherits the domain, and injects nothing). diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index a2565f586..73c4b00bf 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -98,6 +98,7 @@ A `@proxy(...)` rule supports more than just a domain: | `method` | Restrict to one or more HTTP methods, e.g. `method=[GET, POST]`. | | `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]`. | +| `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: @@ -111,6 +112,22 @@ A `@proxy(...)` rule supports more than just a domain: STRIPE_SECRET_KEY=sk_live_... ``` +### Grouping rules for one domain + +When one host needs several path/method policies, write the `domain` once and list the refinements under `rules`: + +```env-spec title=".env.schema" +# @enableProxy(egress="strict") +# --- +# @proxy(domain="api.stripe.com", rules=[ +# {path="/v1/refunds/**", method=[POST, DELETE], block=true}, +# {path="/v1/payouts/**", block=true}, +# ]) +STRIPE_SECRET_KEY=sk_live_... +``` + +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 `approval`, but not `domain` or `keys` (those stay on the parent). + ### Attached vs detached rules - **Attached rule**: `@proxy` on an item. Injects that item's secret into matching requests. (Attach extra items with the `keys` array: `@proxy(domain="api.x.com", keys=[OTHER_KEY])`.) diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index ef3d4dd70..94f158aa4 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,7 +256,7 @@ OPENAI_API_KEY=sk-proj-... 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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). -**Function form** `@proxy(domain=..., [path], [method], [block], [keys])`: +**Function form** `@proxy(domain=..., [path], [method], [block], [keys], [rules])`: | Option | Meaning | |---|---| @@ -265,6 +265,7 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `block` | `block=true` denies matching requests outright. | | `keys` | Array of additional item names to inject for this rule, e.g. `keys=[OTHER_KEY]`. | +| `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` (not `domain`/`keys`) and injects nothing on its own. See the [Grouping rules guide](/guides/proxy/#grouping-rules-for-one-domain). | The same decorator in the **header** creates a _detached_ policy rule (no injection unless it lists `keys`). diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 5bcc05d2e..2decc3e05 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -507,7 +507,7 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow.
### `@proxy()` -**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], ...)` +**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, 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=[...]`. Requires [`@enableProxy`](#enableproxy). diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index bc6eb5a82..dbd73d510 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -132,3 +132,55 @@ describe('isCwdWithin (proxy run attach matching)', () => { expect(isCwdWithin('/x/y', '/a/b')).toBe(false); // unrelated }); }); + +describe('@proxy nested rules=[...] form', () => { + test('desugars to the parent inject rule plus one policy rule per entry (domain written once)', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # --- + # @proxy(domain="api.stripe.com", rules=[ + # {path="/v1/refunds/**", method=[POST, DELETE], block=true}, + # {path="/v1/payouts/**", block=true}, + # ]) + STRIPE_SECRET_KEY=sk_live_realsecret + `); + const rules = await graph.getProxyRules(); + expect(rules).toHaveLength(3); + + // parent rule injects the item across the domain + expect(rules[0]).toMatchObject({ domain: ['api.stripe.com'], itemKeys: ['STRIPE_SECRET_KEY'] }); + expect(rules[0].path).toBeUndefined(); + expect(rules[0].block).toBeUndefined(); + + // entries are policy-only refinements (no injection), inheriting the domain + expect(rules[1]).toMatchObject({ + domain: ['api.stripe.com'], itemKeys: [], path: '/v1/refunds/**', method: ['POST', 'DELETE'], block: true, + }); + expect(rules[2]).toMatchObject({ + domain: ['api.stripe.com'], itemKeys: [], path: '/v1/payouts/**', block: true, + }); + }); + + test('a detached header rules=[...] block inherits the domain and injects nothing', async () => { + const graph = await loadGraph(outdent` + # @enableProxy(egress="strict") + # @proxy(domain="api.example.com", rules=[{path="/admin/**", block=true}]) + # --- + FOO=bar + `); + const rules = await graph.getProxyRules(); + expect(rules).toHaveLength(2); + expect(rules.every((r) => r.itemKeys.length === 0)).toBe(true); + expect(rules[1]).toMatchObject({ domain: ['api.example.com'], path: '/admin/**', block: true }); + }); + + test('rejects an entry that tries to re-set domain (injection is the parent rule\'s job)', async () => { + const graph = await loadGraph(outdent` + # @enableProxy() + # @proxy(domain="api.example.com", rules=[{domain="evil.com", block=true}]) + # --- + FOO=bar + `); + await expect(graph.getProxyRules()).rejects.toThrow(/unknown option "domain" in a rules entry/); + }); +}); diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 59e3e3144..02ab3f8f4 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -341,7 +341,11 @@ 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'] as const; +const VALID_PROXY_OPTIONS = ['domain', 'path', 'method', 'keys', 'block', 'approval', 'rules'] 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). */ +const VALID_PROXY_RULE_ENTRY_OPTIONS = ['path', 'method', 'block', 'approval'] as const; /** Inner options of the `approval={...}` object form. */ const VALID_APPROVAL_OPTIONS = ['enabled', 'each', 'maxDuration'] as const; @@ -402,6 +406,37 @@ function assertProxyApprovalArg(resolver: Resolver | undefined): void { } } +/** + * 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 — + * injection is the parent rule's job). Statically validates literal entries; the + * resolve-time validator re-checks dynamic values. + */ +function assertProxyRulesArg(resolver: Resolver | undefined): void { + if (!resolver) return; + if (!(resolver instanceof ArrayLiteralResolver)) { + throw new SchemaError('@proxy: rules must be an array of rule objects, e.g. rules=[{path="/v1/**", block=true}]'); + } + for (const entry of resolver.arrArgs ?? []) { + if (!(entry instanceof ObjectLiteralResolver)) { + throw new SchemaError('@proxy: each rules entry must be an object, e.g. {path="/v1/**", block=true}'); + } + const inner = entry.objArgs ?? {}; + for (const key of Object.keys(inner)) { + if (!VALID_PROXY_RULE_ENTRY_OPTIONS.includes(key as typeof VALID_PROXY_RULE_ENTRY_OPTIONS[number])) { + throw new SchemaError( + `@proxy: unknown option "${key}" in a rules entry. Valid entry options: ${VALID_PROXY_RULE_ENTRY_OPTIONS.join(', ')} ` + + '(domain and keys are set on the parent @proxy)', + ); + } + } + assertProxyStringListArg(inner.method, 'method', false); + assertProxyStringArg(inner.path, 'path'); + assertProxyBooleanArg(inner.block, 'block'); + assertProxyApprovalArg(inner.approval); + } +} + function validateProxyFunctionArgs(argsVal: Resolver): void { if (!argsVal.objArgs?.domain) { throw new SchemaError('@proxy: missing required "domain" option'); @@ -423,6 +458,7 @@ function validateProxyFunctionArgs(argsVal: Resolver): void { assertProxyStringArg(argsVal.objArgs?.path, 'path'); assertProxyBooleanArg(argsVal.objArgs?.block, 'block'); assertProxyApprovalArg(argsVal.objArgs?.approval); + assertProxyRulesArg(argsVal.objArgs?.rules); if (argsVal.arrArgs?.length) { throw new SchemaError('@proxy: positional args are not supported - use keys=[ITEM_A, ITEM_B] to attach items'); diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 98c78bac4..a00b1b263 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -919,6 +919,16 @@ export class EnvGraph { * security-relevant option (a dropped `block`/`approval` is a permissive rule). */ private static validateResolvedProxyObj(obj: any): void { + // Reject unknown options at resolve time too (the static load-time validator + // doesn't fire for header/root @proxy decorators), so a typo like `blok=true` + // fails loudly instead of silently producing a permissive rule. Entries that + // reach the recursive call have already been filtered to the per-entry set. + const validOptions = ['domain', 'path', 'method', 'keys', 'block', 'approval', 'rules']; + for (const key of Object.keys(obj ?? {})) { + if (!validOptions.includes(key)) { + throw new SchemaError(`@proxy: unknown option "${key}". Valid options: ${validOptions.join(', ')}`); + } + } if (obj?.block !== undefined && !_.isBoolean(obj.block)) { throw new SchemaError(`@proxy: block must resolve to a boolean, got ${JSON.stringify(obj.block)}`); } @@ -952,6 +962,25 @@ export class EnvGraph { } } } + + // `rules=[{...}]`: each entry is a policy refinement for the parent's domain. + if (obj?.rules !== undefined) { + if (!Array.isArray(obj.rules)) { + throw new SchemaError(`@proxy: rules must be an array of rule objects, got ${JSON.stringify(obj.rules)}`); + } + for (const entry of obj.rules) { + if (!_.isPlainObject(entry)) { + throw new SchemaError(`@proxy: each rules entry must be an object, got ${JSON.stringify(entry)}`); + } + for (const key of Object.keys(entry)) { + if (!['path', 'method', 'block', 'approval'].includes(key)) { + throw new SchemaError(`@proxy: unknown option "${key}" in a rules entry. Valid entry options: path, method, block, approval (domain and keys are set on the parent @proxy)`); + } + } + // reuse the per-option type checks for the entry (path/method/block/approval) + EnvGraph.validateResolvedProxyObj(entry); + } + } } /** @@ -985,6 +1014,36 @@ export class EnvGraph { }; } + /** Build one runtime ProxyRule from a resolved `@proxy(...)` arg object (or a `rules` entry). */ + private static buildProxyRuleFromObj(obj: any, domain: Array, itemKeys: Array): ProxyRule { + const method = EnvGraph.normalizeStringList(obj?.method); + return { + domain, + itemKeys, + ...(_.isString(obj?.path) ? { path: obj.path } : {}), + ...(method.length ? { method } : {}), + ...(_.isBoolean(obj?.block) ? { block: obj.block } : {}), + ...EnvGraph.buildProxyApprovalFields(obj), + }; + } + + /** + * Expand a resolved `@proxy(...)` arg object into one or more runtime rules: the + * parent rule (which carries injection via `itemKeys` and the parent's own + * path/method/block) plus one policy-only rule per `rules=[{...}]` entry. Each + * entry inherits `domain`, injects nothing (empty `itemKeys`), and refines via + * precedence (block > require-approval > allow), so the domain is written once. + */ + private static expandProxyRules(obj: any, domain: Array, itemKeys: Array): Array { + const out: Array = [EnvGraph.buildProxyRuleFromObj(obj, domain, itemKeys)]; + if (Array.isArray(obj?.rules)) { + for (const entry of obj.rules) { + out.push(EnvGraph.buildProxyRuleFromObj(entry, domain, [])); + } + } + return out; + } + async getProxyRules(): Promise> { const rules: Array = []; @@ -994,16 +1053,8 @@ export class EnvGraph { EnvGraph.validateResolvedProxyObj(resolved?.obj); const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; - - const method = EnvGraph.normalizeStringList(resolved?.obj?.method); - rules.push({ - domain, - itemKeys: EnvGraph.normalizeStringList(resolved?.obj?.keys), - ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), - ...(method.length ? { method } : {}), - ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...EnvGraph.buildProxyApprovalFields(resolved?.obj), - }); + const itemKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); + rules.push(...EnvGraph.expandProxyRules(resolved?.obj, domain, itemKeys)); } // attached rules from item-level @proxy(...) @@ -1014,18 +1065,9 @@ export class EnvGraph { EnvGraph.validateResolvedProxyObj(resolved?.obj); const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); if (domain.length === 0) continue; - - const method = EnvGraph.normalizeStringList(resolved?.obj?.method); const extraKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); const itemKeys = _.uniq([itemKey, ...extraKeys]); - rules.push({ - domain, - itemKeys, - ...(_.isString(resolved?.obj?.path) ? { path: resolved.obj.path } : {}), - ...(method.length ? { method } : {}), - ...(_.isBoolean(resolved?.obj?.block) ? { block: resolved.obj.block } : {}), - ...EnvGraph.buildProxyApprovalFields(resolved?.obj), - }); + rules.push(...EnvGraph.expandProxyRules(resolved?.obj, domain, itemKeys)); } } From b93bc551cddaf17a7d8b69e6bff1fe406a4d1517 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 12:07:36 -0700 Subject: [PATCH 37/64] docs(proxy): drop em dashes from proxy CLI output and messages Replace em dashes with colons/commas/semicolons in the user-facing proxy strings: the `proxy rules` summary labels, the --allow-reload help text, the refresh and schema-fingerprint-drift messages, and the approval prompts. Internal code comments are left for the broader em-dash sweep. --- packages/varlock/src/cli/commands/proxy.command.ts | 14 +++++++------- .../varlock/src/cli/helpers/proxy-context-guard.ts | 2 +- .../src/cli/helpers/proxy-schema-fingerprint.ts | 2 +- packages/varlock/src/proxy/approval.ts | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index d7d534d55..aa4ed2c58 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -112,7 +112,7 @@ export const commandSpec = define({ }, 'allow-reload': { type: 'boolean', - description: 'For `proxy start`/`run`: enable live policy hot-reload via `proxy refresh`. Off by default — ' + description: 'For `proxy start`/`run`: enable live policy hot-reload via `proxy refresh`. Off by default; ' + 'the reload channel is unauthenticated on a shared uid, so a same-uid agent could self-approve a schema ' + 'edit. Only enable it alongside a sandbox (or once the out-of-band approver ships).', }, @@ -1074,7 +1074,7 @@ async function refreshAction(ctx: any) { { suggestion: 'Restart the proxy to pick up schema edits, or start it with ' + '`varlock proxy start --allow-reload` to enable live reloads. Note: the reload ' - + 'channel is unauthenticated on a shared uid — only enable it behind a sandbox.', + + 'channel is unauthenticated on a shared uid, so only enable it behind a sandbox.', }, ); } @@ -1283,7 +1283,7 @@ async function rulesAction(ctx: any) { console.log(ansis.bold(`Rules (${rules.length})`)); if (!rules.length) { - console.log(ansis.dim(' (none — add @proxy(domain=...) to route a secret)')); + console.log(ansis.dim(' (none; add @proxy(domain=...) to route a secret)')); } else { for (const rule of rules) { const target = rule.domain.join(', ') @@ -1303,13 +1303,13 @@ async function rulesAction(ctx: any) { const item = envGraph.configSchema[key]; if (!item) continue; if (managedKeys.has(key)) { - secrets.push({ key, label: `${ansis.green('proxied')} — placeholder; real value injected on matching hosts` }); + secrets.push({ key, label: `${ansis.green('proxied')}: placeholder; real value injected on matching hosts` }); } else if (omittedSet.has(key)) { - secrets.push({ key, label: `${ansis.yellow('omit')} — withheld from the child entirely` }); + secrets.push({ key, label: `${ansis.yellow('omit')}: withheld from the child entirely` }); } else if (getProxyValueMode(item) === 'passthrough') { - secrets.push({ key, label: `${ansis.red('passthrough')} — real value sent to the child` }); + secrets.push({ key, label: `${ansis.red('passthrough')}: real value sent to the child` }); } else if (placeholderByKey[key]) { - secrets.push({ key, label: `${ansis.cyan('placeholder')} — sensitive, no rule (not injected anywhere)` }); + secrets.push({ key, label: `${ansis.cyan('placeholder')}: sensitive, no rule (not injected anywhere)` }); } } diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index 1a48e8305..61973c149 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -30,7 +30,7 @@ async function isInProxyContext(env: NodeJS.ProcessEnv): Promise { if (session) { debug( 'proxy context detected via process ancestry with the env marker absent ' - + '(possible bypass attempt) — session %s', + + '(possible bypass attempt), session %s', session.id, ); return true; diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index f9c514de8..6d372e6c0 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -125,7 +125,7 @@ export async function enforceProxySchemaFingerprint( if (actual === expected) return; throw new CliExitError('Schema changed inside an active proxy session.', { - details: 'The resolved .env.schema no longer matches the fingerprint captured when the proxy session started. This guards against editing the schema mid-session — e.g. downgrading @sensitive, or adding a new item that resolves a secret — to recover real values.', + details: 'The resolved .env.schema no longer matches the fingerprint captured when the proxy session started. This guards against editing the schema mid-session (e.g. downgrading @sensitive, or adding a new item that resolves a secret) to recover real values.', suggestion: 'Restart the proxy from a trusted (non-proxied) context to pick up the change. (If it was started with `varlock proxy start --allow-reload`, run `varlock proxy refresh` from a trusted context instead.)', forceExit: true, }); diff --git a/packages/varlock/src/proxy/approval.ts b/packages/varlock/src/proxy/approval.ts index bfa0d5919..e2d523b89 100644 --- a/packages/varlock/src/proxy/approval.ts +++ b/packages/varlock/src/proxy/approval.ts @@ -229,7 +229,7 @@ export function createTtyApprovalProvider(opts?: { options.push('[n] no'); const inj = req.injectedKeys?.length ? ` injecting [${req.injectedKeys.join(', ')}]` : ''; - const prompt = '\n🔐 varlock proxy — approval required\n' + const prompt = '\n🔐 varlock proxy: approval required\n' + ` ${req.method} https://${req.host}${req.path}${inj}\n${ req.ruleId ? ` rule: ${req.ruleId}\n` : '' } Approve? ${options.join(' ')} `; @@ -238,7 +238,7 @@ export function createTtyApprovalProvider(opts?: { let answered = false; const answer = await new Promise((resolve) => { const timer = setTimeout(() => { - output.write('\n (timed out — denied)\n'); + output.write('\n (timed out, denied)\n'); resolve(''); }, timeoutMs); timer.unref?.(); @@ -255,7 +255,7 @@ export function createTtyApprovalProvider(opts?: { // redirected). Surface that rather than denying silently. if (!answered) { output.write( - '\n (couldn\'t read your answer — run `varlock proxy start` in the ' + '\n (couldn\'t read your answer; run `varlock proxy start` in the ' + 'foreground of an interactive terminal to approve requests)\n', ); } From 2028d54dc2b94d3f9517f5fad6a3031b5f44ed20 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 12:08:46 -0700 Subject: [PATCH 38/64] docs(agents): clarify a plain hyphen is fine; only em/en dashes are banned --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8768dfb6a..0ff713d43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,4 +67,4 @@ This is a monorepo managed with bun workspaces and Turborepo: ## Writing style -- **Do not use em dashes (`—`) or en dashes (`–`)** in any prose you write: docs, code comments, commit messages, PR descriptions, changeset entries, or design notes. They read as an AI-writing tell. Rewrite with a colon, comma, semicolon, parentheses, or two sentences instead. +- **Do not use em dashes (`—`) or en dashes (`–`)** in any prose you write: docs, code comments, commit messages, PR descriptions, changeset entries, or design notes. They read as an AI-writing tell. Rewrite with a colon, comma, semicolon, parentheses, two sentences, or a plain hyphen (`-`) where that reads naturally. Only the em/en dash characters are banned; a regular hyphen is fine. From 1aae5e7b217668ed649565ef5a69e9f79fcec58c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 12:13:35 -0700 Subject: [PATCH 39/64] docs(proxy): explain the session model and how commands target a session Add a Sessions section to the guide and a note in the CLI reference: every proxy command operates on a session, targeted with --session or auto-resolved. `run` attaches to the daemon for the current directory (else starts its own); env/status/audit/refresh/stop use the single active session and ask for --session when more than one is running. --- .../varlock-website/src/content/docs/guides/proxy.mdx | 11 +++++++++++ .../src/content/docs/reference/cli-commands.mdx | 2 ++ 2 files changed, 13 insertions(+) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 73c4b00bf..4517e5dfb 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -220,6 +220,17 @@ node agent.js # now routed through the proxy `proxy env` prints the proxy + CA environment for an existing session so you can source it into any shell or tool. +## Sessions + +Every `varlock proxy` command operates on a **session**: one running proxy with its own short id (printed by `proxy start`, listed by `proxy status`). You target a session in one of two ways: + +- **By id:** pass `--session ` to any command (`proxy env --session abc12`, `proxy stop --session abc12`, etc.). +- **Auto-discovered:** with no `--session`, the command resolves one for you: + - `proxy run` attaches to the running `proxy start` daemon **for the current directory** (a session whose directory is this one or a parent of it). If there isn't one it starts its own proxy; pass `--new` to always start a fresh, separate one. + - `env` / `status` / `audit` / `refresh` / `stop` use the **single active session**. If more than one is running they ask you to pass `--session `; commands run from inside a proxied child target that child's own session automatically. + +Sessions are durable records: they persist after the proxy stops (visible with `proxy status --all`) so their [audit log](#auditing) stays available. + ## How the child is wired `proxy run` (and `proxy env`) inject a standard set of environment variables into the child so common HTTP clients trust the proxy with no manual setup: diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index c10af901c..ad4592906 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -645,6 +645,8 @@ Manages [credential proxy](/guides/proxy/) sessions: running an untrusted child varlock proxy [options] ``` +Every subcommand operates on a [session](/guides/proxy/#sessions). Target one with `--session `, or let it auto-resolve: `run` attaches to the daemon for the current directory (else starts its own), and the other subcommands use the single active session (asking for `--session` if more than one is running). + **Subcommands:** - `run -- `: Start a proxy, run `` through it, and tear down on exit. Attaches to a running `proxy start` session for this directory if one exists; otherwise runs self-contained. - `start`: Start a long-lived proxy session that owns the terminal (a live request log appears here). Stop with `Ctrl+C`. From 8c440c89c4989799cd15c9a2121b5893201dd9da Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 12:49:10 -0700 Subject: [PATCH 40/64] docs(proxy): name the credential-broker pattern and link references Call out that the credential proxy is a local implementation of the credential broker pattern, with links to the SANS write-up and Anthropic's managed-agents architecture. Mention the term in the page description for discoverability. --- packages/varlock-website/src/content/docs/guides/proxy.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 4517e5dfb..e89274e1e 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -1,12 +1,14 @@ --- title: Credential proxy -description: Run AI agents and untrusted tools through a local proxy so they only ever see placeholder secrets, while real values are injected at the network boundary. +description: Run AI agents and untrusted tools through a local credential broker so they only ever see placeholder secrets, while real values are injected at the network boundary. --- The credential proxy lets you run an AI agent (or any untrusted tool) so that it **never sees your real secrets**: it only ever has placeholders in its environment. The real values are injected into outbound requests at the network boundary, against a cryptographically verified upstream, and every request is logged. This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it: coding agents, MCP servers, third-party CLIs, or scripts. +This pattern is known as a **credential broker** (or credential brokering): a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the [SANS Institute](https://www.sans.org/blog/your-ai-agent-easily-confused-deputy-why-cloud-security-needs-credential-broker) and in [Anthropic's managed-agents architecture](https://www.anthropic.com/engineering/managed-agents). varlock's credential proxy is a local implementation you run on your own machine. + :::caution[Preview] The credential proxy is an early preview. The core protection (placeholder isolation + verified-identity wire injection) is solid, but there are real limits to what it covers and it runs on the same machine/user as the agent. Read [Limitations](#limitations) before relying on it as a security boundary. ::: From c0ba87f19445e7e3bc55d9cd1417a54f4b8bbbec Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 13:00:53 -0700 Subject: [PATCH 41/64] docs(proxy): show @sensitive explicitly in the examples Add @sensitive to the item examples in the proxy guide and note that @proxy already implies it (so the line is optional), since being explicit about what is a secret is good practice. --- .../varlock-website/src/content/docs/guides/proxy.mdx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index e89274e1e..dbaac877f 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -16,6 +16,7 @@ The credential proxy is an early preview. The core protection (placeholder isola ```env-spec title=".env.schema" # @enableProxy(egress="permissive") # --- +# @sensitive # @proxy(domain="api.openai.com") # @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=sk-... @@ -53,19 +54,23 @@ Add the [`@enableProxy`](/reference/root-decorators/#enableproxy) root decorator #### 2. Route a secret through the proxy -Add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** you want to protect. This both marks the item [`@sensitive`](/reference/item-decorators/#sensitive) and tells the proxy to inject its real value into requests to that host: +Mark the item [`@sensitive`](/reference/item-decorators/#sensitive) and add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** you want to protect. The `@proxy` tells the proxy to inject the item's real value into requests to that host: ```env-spec title=".env.schema" # @enableProxy(egress="permissive") # --- +# @sensitive # @proxy(domain="api.openai.com") # @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=sk-... +# @sensitive # @proxy(domain="api.stripe.com") STRIPE_SECRET_KEY=sk_live_... ``` +`@proxy` already implies [`@sensitive`](/reference/item-decorators/#sensitive) (and varlock treats items as sensitive by default), so the `@sensitive` line is optional. We show it explicitly here because being clear about what is a secret is good practice. + The `@placeholder` on `OPENAI_API_KEY` makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like `STRIPE_SECRET_KEY` above) and the item gets a generic `vlk_placeholder_…`, which varlock warns about because it can fail a client-side key-format check (see [Placeholders](#placeholders)). #### 3. Check your config (optional) @@ -110,6 +115,7 @@ A `@proxy(...)` rule supports more than just a domain: # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # --- # Match either host, any method: +# @sensitive # @proxy(domain=[api.stripe.com, api.stripe-test.com]) STRIPE_SECRET_KEY=sk_live_... ``` @@ -121,6 +127,7 @@ When one host needs several path/method policies, write the `domain` once and li ```env-spec title=".env.schema" # @enableProxy(egress="strict") # --- +# @sensitive # @proxy(domain="api.stripe.com", rules=[ # {path="/v1/refunds/**", method=[POST, DELETE], block=true}, # {path="/v1/payouts/**", block=true}, @@ -183,6 +190,7 @@ Every placeholder is unique per item, so two different secrets can never collide If you see the generic-placeholder warning, add an `@placeholder` or a typed format so the placeholder looks valid to the client: ```env-spec title=".env.schema" +# @sensitive # @proxy(domain="api.openai.com") # @placeholder=sk-proj-000000000000000000000000 OPENAI_API_KEY=sk-proj-... From f831e5db47e80e2c7eb1cca69f47e88469bf4810 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 13:03:00 -0700 Subject: [PATCH 42/64] feat(proxy): varlock explain shows when @proxy forces a key sensitive The sensitivity source now distinguishes the @proxy case, so `varlock explain ` reads "Sensitive: yes (forced sensitive by its @proxy rule, so the agent only sees a placeholder)". Also drops an em dash from the default explanation. --- packages/varlock/src/cli/commands/explain.command.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/varlock/src/cli/commands/explain.command.ts b/packages/varlock/src/cli/commands/explain.command.ts index 1c720f85e..2cdf98f27 100644 --- a/packages/varlock/src/cli/commands/explain.command.ts +++ b/packages/varlock/src/cli/commands/explain.command.ts @@ -22,7 +22,8 @@ function describeSensitiveSource(item: ConfigItem): string { case 'resolver': return `inferred from the ${item.valueResolver?.fnName}() resolver`; case 'default-decorator': return 'from @defaultSensitive'; case 'prefix': return 'from a @defaultSensitive prefix rule'; - default: return 'default — items are sensitive unless marked @public'; + case 'proxy': return 'forced sensitive by its @proxy rule, so the agent only sees a placeholder'; + default: return 'default: items are sensitive unless marked @public'; } } From 974a6077b8548978d274952735e1aeaa74d41e92 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 13:13:29 -0700 Subject: [PATCH 43/64] docs(proxy): add a flow diagram near the top of the guide Inline themed SVG (adapts to light/dark via currentColor + the Starlight accent variable, no new build dependency) showing the request/response flow: the agent holds placeholders, the local proxy injects the real secret toward a verified upstream, and scrubs the response back to a placeholder. --- .../src/content/docs/guides/proxy.mdx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index dbaac877f..4946b48c4 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -9,6 +9,49 @@ This is useful any time a process you don't fully trust needs to _use_ a secret This pattern is known as a **credential broker** (or credential brokering): a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the [SANS Institute](https://www.sans.org/blog/your-ai-agent-easily-confused-deputy-why-cloud-security-needs-credential-broker) and in [Anthropic's managed-agents architecture](https://www.anthropic.com/engineering/managed-agents). varlock's credential proxy is a local implementation you run on your own machine. +
+ + + + + + + + + + + + + + + + Agent + untrusted child + holds placeholders + varlock proxy + local, in memory + swap / verify TLS / scrub + Upstream API + verified TLS host + + + + + + + + + + + placeholder + real secret + scrubbed + response + + +
The agent only ever holds a placeholder. The proxy swaps in the real secret on the wire toward a verified upstream, and scrubs it back out of the response.
+
+ :::caution[Preview] The credential proxy is an early preview. The core protection (placeholder isolation + verified-identity wire injection) is solid, but there are real limits to what it covers and it runs on the same machine/user as the agent. Read [Limitations](#limitations) before relying on it as a security boundary. ::: From 9325f516333054469e0c9e5ffab632ed4a8837bc Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 13:16:51 -0700 Subject: [PATCH 44/64] docs(website): add a credential proxy section to the homepage A new feature section showing the @proxy schema and `varlock proxy run -- claude`: agents only ever see placeholders while real values are injected at the verified network boundary and scrubbed from responses (the credential broker pattern), with a link to the guide. --- .../varlock-website/src/pages/index.astro | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/varlock-website/src/pages/index.astro b/packages/varlock-website/src/pages/index.astro index ebd1245ef..34a36af7a 100644 --- a/packages/varlock-website/src/pages/index.astro +++ b/packages/varlock-website/src/pages/index.astro @@ -327,6 +327,32 @@ curl -sSfL https://varlock.dev/install.sh | sh -s Use varlock run to inject resolved, validated env vars into another process.

+ +

+ + Credential proxy for AI agents +

+ + + +

+ Run an agent (or any untrusted tool) so it only ever sees a placeholder. + The real value is injected at the network boundary against a cryptographically + verified upstream, and scrubbed back out of responses, so a prompt-injected or + compromised agent has nothing to leak. This is the credential broker pattern; + read the guide. +

From 30d340a43e1086b6bfcdb49fb301c03742c87ac7 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 26 Jun 2026 13:30:02 -0700 Subject: [PATCH 45/64] docs(website): make the homepage credential-proxy section a large highlight Expand it into a bordered featured block: the flow diagram, a lead paragraph, the schema and command, three key points (placeholder isolation, verified-identity injection, scrub and audit), and a CTA to the guide. --- .../varlock-website/src/pages/index.astro | 154 +++++++++++++++--- 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/packages/varlock-website/src/pages/index.astro b/packages/varlock-website/src/pages/index.astro index 34a36af7a..2dc2eb76c 100644 --- a/packages/varlock-website/src/pages/index.astro +++ b/packages/varlock-website/src/pages/index.astro @@ -328,31 +328,97 @@ curl -sSfL https://varlock.dev/install.sh | sh -s process.

-

- - Credential proxy for AI agents -

+
+

+ + Credential proxy for AI agents +

+

+ Run coding agents, MCP servers, and any untrusted tool so they only ever see + placeholder secrets. The real values are injected at the network boundary + against a cryptographically verified upstream, and scrubbed back out of responses, + so a prompt-injected or compromised agent has nothing to leak. This is the + credential broker pattern, run locally on your own machine. +

+ +
+ + + + + + + + + + + + + + + + Agent + untrusted child + holds placeholders + varlock proxy + local, in memory + swap / verify TLS / scrub + Upstream API + verified TLS host + + + + + + + + + + + placeholder + real secret + scrubbed + response + + +
- +
+ - -

- Run an agent (or any untrusted tool) so it only ever sees a placeholder. - The real value is injected at the network boundary against a cryptographically - verified upstream, and scrubbed back out of responses, so a prompt-injected or - compromised agent has nothing to leak. This is the credential broker pattern; - read the guide. -

+ ` + /> + +
+
    +
  • + Placeholder isolation: the agent never holds a real secret, even if + it re-runs varlock load or reads its own environment. +
  • +
  • + Verified-identity injection: secrets are sent only to upstreams whose + TLS identity checks out against public CAs, never over cleartext. +
  • +
  • + Scrub and audit: real values are scrubbed back out of responses, and + every request is logged with no secret values. +
  • +
+
+ +
+ Read the credential proxy guide +
+
@@ -585,6 +651,54 @@ OPENAI_API_KEY=sk-... font-weight: bold; } + /* Credential proxy feature highlight */ + .proxy-feature { + margin: 3.5rem 0 1rem; + border: 2px dotted var(--brand-cyan--t1); + border-radius: 14px; + padding: 0.5rem 2rem 2rem; + } + .proxy-feature h2 { + margin-top: 1rem; + } + .proxy-lead { + font-size: 1.2rem; + line-height: 1.5; + text-align: center; + max-width: 800px; + margin: 0.5rem auto 1.5rem; + } + .proxy-diagram { + max-width: 760px; + margin: 1.5rem auto; + } + .proxy-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem 2.5rem; + align-items: center; + margin-top: 1.5rem; + @media screen and (max-width: 700px) { + grid-template-columns: 1fr; + } + } + .proxy-points { + list-style: none; + padding-left: 0; + margin: 0; + } + .proxy-points li { + position: relative; + margin: 0.85rem 0; + padding-left: 1.5rem; + } + .proxy-points li::before { + content: "▸"; + position: absolute; + left: 0; + color: var(--brand-cyan); + } + .custom-badge { display: flex; align-items: center; From 6c4a33512e9faa8e6be76fb76990eb44416dd372 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 29 Jun 2026 15:32:58 -0700 Subject: [PATCH 46/64] docs(website): homepage proxy section uses "credential broker", no border Drop the dotted border, retitle to "Credential broker for AI agents" (the established term), and return the lead text to normal size. --- packages/varlock-website/src/pages/index.astro | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/varlock-website/src/pages/index.astro b/packages/varlock-website/src/pages/index.astro index 2dc2eb76c..4f3378694 100644 --- a/packages/varlock-website/src/pages/index.astro +++ b/packages/varlock-website/src/pages/index.astro @@ -333,14 +333,14 @@ curl -sSfL https://varlock.dev/install.sh | sh -s - Credential proxy for AI agents + Credential broker for AI agents

Run coding agents, MCP servers, and any untrusted tool so they only ever see placeholder secrets. The real values are injected at the network boundary against a cryptographically verified upstream, and scrubbed back out of responses, - so a prompt-injected or compromised agent has nothing to leak. This is the - credential broker pattern, run locally on your own machine. + so a prompt-injected or compromised agent has nothing to leak. varlock runs the + broker locally on your own machine, so your real secrets never leave it.

@@ -651,20 +651,12 @@ OPENAI_API_KEY=sk-... font-weight: bold; } - /* Credential proxy feature highlight */ + /* Credential broker feature highlight */ .proxy-feature { margin: 3.5rem 0 1rem; - border: 2px dotted var(--brand-cyan--t1); - border-radius: 14px; - padding: 0.5rem 2rem 2rem; - } - .proxy-feature h2 { - margin-top: 1rem; } .proxy-lead { - font-size: 1.2rem; line-height: 1.5; - text-align: center; max-width: 800px; margin: 0.5rem auto 1.5rem; } From efef840798f33f7bf042e790c9b1a4774015bfb1 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 29 Jun 2026 15:34:55 -0700 Subject: [PATCH 47/64] docs(proxy): note a sandbox makes the proxy a real boundary The preview caution framed "same machine/user as the agent" as a flat limitation. On its own the proxy raises the bar; paired with an OS sandbox or container it becomes a hard boundary. Reword the caution accordingly. --- packages/varlock-website/src/content/docs/guides/proxy.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 4946b48c4..3fe13d2bb 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -53,7 +53,7 @@ This pattern is known as a **credential broker** (or credential brokering): a tr :::caution[Preview] -The credential proxy is an early preview. The core protection (placeholder isolation + verified-identity wire injection) is solid, but there are real limits to what it covers and it runs on the same machine/user as the agent. Read [Limitations](#limitations) before relying on it as a security boundary. +The credential proxy is an early preview. Its core protection (placeholder isolation + verified-identity wire injection) is solid, but on its own it runs as the same user as the agent, so it raises the bar rather than being a hard boundary. Pair it with an OS sandbox or container and it becomes a real one. Read [Limitations](#limitations) before relying on it as a security boundary. ::: ```env-spec title=".env.schema" From dd5131a2d1a730cfe6c87bcb57b7c8bc5ba186a7 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 29 Jun 2026 18:29:55 -0700 Subject: [PATCH 48/64] docs(proxy): @enableProxy is optional egress config, not required The proxy is driven by @proxy decorators on items; a bare or permissive @enableProxy is a no-op, so stop presenting it as required. Drop it from the permissive examples and the quick-start "enable" step, keep @enableProxy(egress="strict") only where strict mode is shown, and correct the "Requires @enableProxy" claims in the CLI and root-decorator references and the homepage. Includes intro/positioning copy refinements. --- .../src/content/docs/guides/proxy.mdx | 40 +++++++------------ .../content/docs/reference/cli-commands.mdx | 2 +- .../docs/reference/root-decorators.mdx | 5 +-- .../varlock-website/src/pages/index.astro | 6 +-- 4 files changed, 20 insertions(+), 33 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 3fe13d2bb..7b9fac21e 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -3,11 +3,13 @@ title: Credential proxy description: Run AI agents and untrusted tools through a local credential broker so they only ever see placeholder secrets, while real values are injected at the network boundary. --- -The credential proxy lets you run an AI agent (or any untrusted tool) so that it **never sees your real secrets**: it only ever has placeholders in its environment. The real values are injected into outbound requests at the network boundary, against a cryptographically verified upstream, and every request is logged. +The credential proxy lets you run an AI agent (or any untrusted component) so that it **never sees real secrets**. Instead it receives _placeholders_ and real values are swapped in at the network boundary. This is useful any time a process you don't fully trust needs to _use_ a secret without being allowed to _read_ it: coding agents, MCP servers, third-party CLIs, or scripts. -This pattern is known as a **credential broker** (or credential brokering): a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the [SANS Institute](https://www.sans.org/blog/your-ai-agent-easily-confused-deputy-why-cloud-security-needs-credential-broker) and in [Anthropic's managed-agents architecture](https://www.anthropic.com/engineering/managed-agents). varlock's credential proxy is a local implementation you run on your own machine. +This pattern is known as a **credential broker**: a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the [SANS Institute](https://www.sans.org/blog/your-ai-agent-easily-confused-deputy-why-cloud-security-needs-credential-broker) and in [Anthropic's managed-agents architecture](https://www.anthropic.com/engineering/managed-agents). + +Most other credential brokers require you to store secrets in their tool, or work only with a limited subset of secure storage locations (system keychain, 1Password). Our proxy works with the existing varlock suite - so you can use any of our [plugins](/plugins/overview/), and still take advantage of the rest of varlock's features.
@@ -56,13 +58,10 @@ This pattern is known as a **credential broker** (or credential brokering): a tr The credential proxy is an early preview. Its core protection (placeholder isolation + verified-identity wire injection) is solid, but on its own it runs as the same user as the agent, so it raises the bar rather than being a hard boundary. Pair it with an OS sandbox or container and it becomes a real one. Read [Limitations](#limitations) before relying on it as a security boundary. ::: -```env-spec title=".env.schema" -# @enableProxy(egress="permissive") -# --- +```diff lang="env-spec" title=".env.schema" # @sensitive -# @proxy(domain="api.openai.com") -# @placeholder=sk-proj-000000000000000000000000 -OPENAI_API_KEY=sk-... ++# @proxy(domain="api.openai.com") @placeholder=sk-proj-000000000000000000000000 +OPENAI_API_KEY=yourPreferredPlugin() # load from secure vault ``` ```bash @@ -86,22 +85,11 @@ Secrets and the proxy's signing key live only in memory on your machine; they ar This assumes you already have varlock [installed](/getting-started/installation/) and a working `.env.schema` whose secrets resolve (committed encrypted values, a [plugin](/plugins/overview/), or env values you can load). -#### 1. Enable the proxy - -Add the [`@enableProxy`](/reference/root-decorators/#enableproxy) root decorator to your schema header: - -```env-spec title=".env.schema" -# @enableProxy(egress="permissive") -# --- -``` - -#### 2. Route a secret through the proxy +#### 1. Route a secret through the proxy Mark the item [`@sensitive`](/reference/item-decorators/#sensitive) and add [`@proxy(domain=...)`](/reference/item-decorators/#proxy) **to the item** you want to protect. The `@proxy` tells the proxy to inject the item's real value into requests to that host: ```env-spec title=".env.schema" -# @enableProxy(egress="permissive") -# --- # @sensitive # @proxy(domain="api.openai.com") # @placeholder=sk-proj-000000000000000000000000 @@ -116,13 +104,15 @@ STRIPE_SECRET_KEY=sk_live_... The `@placeholder` on `OPENAI_API_KEY` makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like `STRIPE_SECRET_KEY` above) and the item gets a generic `vlk_placeholder_…`, which varlock warns about because it can fail a client-side key-format check (see [Placeholders](#placeholders)). -#### 3. Check your config (optional) +That is all the proxy needs: there is no separate "enable" step. It runs in **permissive** mode by default (hosts that don't match a rule pass through untouched). Add [`@enableProxy(egress="strict")`](#egress-modes) to your schema header only when you want to block everything that isn't explicitly routed. + +#### 2. Check your config (optional) ```bash varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started ``` -#### 4. Run your agent through it +#### 3. Run your agent through it ```bash varlock proxy run -- claude @@ -187,10 +177,10 @@ This injects `STRIPE_SECRET_KEY` across `api.stripe.com` and blocks refunds and ### Egress modes -`@enableProxy(egress=...)` controls what happens to requests that **don't** match any rule: +The [`@enableProxy(egress=...)`](/reference/root-decorators/#enableproxy) header decorator controls what happens to requests that **don't** match any rule. It's the only reason to write `@enableProxy` at all: the proxy works without it, and `egress` defaults to `permissive`. -- `permissive` _(default)_: unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. -- `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. +- `permissive` _(default, no decorator needed)_: unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. +- `strict`: add `@enableProxy(egress="strict")` to the header so only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. ## Controlling what the agent sees diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index ad4592906..b9e3fd2f6 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -639,7 +639,7 @@ You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` env
### `varlock proxy` ||proxy|| -Manages [credential proxy](/guides/proxy/) sessions: running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in your schema. +Manages [credential proxy](/guides/proxy/) sessions: running an untrusted child process so it only sees placeholder secrets while real values are injected at the network boundary. Route secrets by adding [`@proxy(domain=...)`](/reference/item-decorators/#proxy) to the items you want to protect. ```bash varlock proxy [options] diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 2decc3e05..dc63622b7 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -489,7 +489,7 @@ API_KEY= ### `@enableProxy()` **Arg types:** `(egress?: "permissive" | "strict")` -Enables the [credential proxy](/guides/proxy/), which lets you run an untrusted child process (e.g. an AI agent) that only ever sees placeholder secrets while real values are injected at the network boundary. +Configures [credential proxy](/guides/proxy/) egress. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**: it exists only to change the egress mode from the default. The `egress` option controls requests that don't match any [`@proxy`](#proxy) rule: - `permissive` _(default)_: unmatched hosts pass through untouched. @@ -509,12 +509,11 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow. ### `@proxy()` **Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, 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=[...]`. Requires [`@enableProxy`](#enableproxy). +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=[...]`. 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). ```env-spec -# @enableProxy(egress="permissive") # Block a dangerous endpoint regardless of which key would be used: # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # Inject several keys for a host: diff --git a/packages/varlock-website/src/pages/index.astro b/packages/varlock-website/src/pages/index.astro index 4f3378694..565aa6abc 100644 --- a/packages/varlock-website/src/pages/index.astro +++ b/packages/varlock-website/src/pages/index.astro @@ -339,8 +339,8 @@ curl -sSfL https://varlock.dev/install.sh | sh -s Run coding agents, MCP servers, and any untrusted tool so they only ever see placeholder secrets. The real values are injected at the network boundary against a cryptographically verified upstream, and scrubbed back out of responses, - so a prompt-injected or compromised agent has nothing to leak. varlock runs the - broker locally on your own machine, so your real secrets never leave it. + so a prompt-injected or compromised agent has nothing to leak. You run the broker + yourself, on the same host as the agent, so the real secrets never go to a third party.

@@ -391,8 +391,6 @@ curl -sSfL https://varlock.dev/install.sh | sh -s title=".env.schema" lang="env-spec" code=` -# @enableProxy() -# --- # @sensitive @proxy(domain="api.openai.com") OPENAI_API_KEY=sk-... ` From d382db9692935fa54be4f4e75558d312f554f82e Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 29 Jun 2026 22:39:56 -0700 Subject: [PATCH 49/64] refactor(proxy): rename @enableProxy to @proxyConfig (object-value form) @enableProxy never enabled the proxy (the @proxy item decorators do that); it only configured egress, so the name was misleading. Renamed to @proxyConfig and switched it from a function call to a single-use object value: @proxyConfig={egress="strict"}. egress still defaults to permissive when the decorator is absent. Validation now rejects a non-object value, unknown keys (so a typo like {egres=...} fails loud instead of silently staying permissive), and the function-call form (pointing at the object form). Pre-release, no deprecation. Docs and tests updated. --- .../src/content/docs/guides/proxy.mdx | 12 +++---- .../docs/reference/item-decorators.mdx | 4 +-- .../docs/reference/root-decorators.mdx | 8 ++--- .../cli/commands/test/proxy.command.test.ts | 5 ++- .../test/proxy-schema-fingerprint.test.ts | 12 +++---- .../varlock/src/env-graph/lib/decorators.ts | 32 ++++++++++++------- .../varlock/src/env-graph/lib/env-graph.ts | 12 ++----- .../src/env-graph/test/proxy-mode.test.ts | 8 ++--- 8 files changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 7b9fac21e..48df580e3 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -104,7 +104,7 @@ STRIPE_SECRET_KEY=sk_live_... The `@placeholder` on `OPENAI_API_KEY` makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like `STRIPE_SECRET_KEY` above) and the item gets a generic `vlk_placeholder_…`, which varlock warns about because it can fail a client-side key-format check (see [Placeholders](#placeholders)). -That is all the proxy needs: there is no separate "enable" step. It runs in **permissive** mode by default (hosts that don't match a rule pass through untouched). Add [`@enableProxy(egress="strict")`](#egress-modes) to your schema header only when you want to block everything that isn't explicitly routed. +That is all the proxy needs: there is no separate "enable" step. It runs in **permissive** mode by default (hosts that don't match a rule pass through untouched). Add [`@proxyConfig={egress="strict"}`](#egress-modes) to your schema header only when you want to block everything that isn't explicitly routed. #### 2. Check your config (optional) @@ -143,7 +143,7 @@ A `@proxy(...)` rule supports more than just a domain: `domain` and `method` take either a single value or an **array literal** for lists: ```env-spec title=".env.schema" -# @enableProxy(egress="strict") +# @proxyConfig={egress="strict"} # Block a dangerous endpoint entirely (detached rule, no injection): # @proxy(domain="api.stripe.com", path="/v1/refunds/**", method=[POST, DELETE], block=true) # --- @@ -158,7 +158,7 @@ STRIPE_SECRET_KEY=sk_live_... When one host needs several path/method policies, write the `domain` once and list the refinements under `rules`: ```env-spec title=".env.schema" -# @enableProxy(egress="strict") +# @proxyConfig={egress="strict"} # --- # @sensitive # @proxy(domain="api.stripe.com", rules=[ @@ -177,10 +177,10 @@ This injects `STRIPE_SECRET_KEY` across `api.stripe.com` and blocks refunds and ### Egress modes -The [`@enableProxy(egress=...)`](/reference/root-decorators/#enableproxy) header decorator controls what happens to requests that **don't** match any rule. It's the only reason to write `@enableProxy` at all: the proxy works without it, and `egress` defaults to `permissive`. +The [`@proxyConfig={egress=...}`](/reference/root-decorators/#proxyconfig) header decorator controls what happens to requests that **don't** match any rule. It's the only reason to write `@proxyConfig` at all: the proxy works without it, and `egress` defaults to `permissive`. - `permissive` _(default, no decorator needed)_: unmatched hosts pass through untouched (no injection, no blocking). Good for getting started. -- `strict`: add `@enableProxy(egress="strict")` to the header so only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. +- `strict`: add `@proxyConfig={egress="strict"}` to the header so only requests matching a `@proxy` rule are allowed; everything else is blocked. This is the recommended posture once your rules are dialed in, since it prevents the agent from reaching arbitrary hosts. ## Controlling what the agent sees @@ -333,7 +333,7 @@ The proxy is a local, same-machine tool. Know what it does and doesn't cover bef ## Reference -- [`@enableProxy`](/reference/root-decorators/#enableproxy) and [`@proxy`](/reference/root-decorators/#proxy) root decorators +- [`@proxyConfig`](/reference/root-decorators/#proxyconfig) and [`@proxy`](/reference/root-decorators/#proxy) root decorators - [`@proxy`](/reference/item-decorators/#proxy) item decorator - [`varlock proxy`](/reference/cli-commands/#proxy) CLI commands - [AI Tools guide](/guides/ai-tools/) diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 94f158aa4..9f115b9ff 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -254,7 +254,7 @@ OPENAI_API_KEY=sk-proj-... ### `@proxy` **Value type:** function `@proxy(domain=..., ...)` _or_ value `@proxy=passthrough|omit` -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. Requires [`@enableProxy`](/reference/root-decorators/#enableproxy) in the header. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). +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], [keys], [rules])`: @@ -272,8 +272,6 @@ The same decorator in the **header** creates a _detached_ policy rule (no inject **Value form** `@proxy=passthrough` injects the real value into the child (escape hatch); `@proxy=omit` explicitly withholds it. The value and function forms are mutually exclusive on one item. ```env-spec -# @enableProxy(egress="strict") -# --- # @proxy(domain="api.stripe.com", path="/v1/**") STRIPE_SECRET_KEY=sk_live_... diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index dc63622b7..a0c6f8b1d 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -486,17 +486,17 @@ API_KEY=
-### `@enableProxy()` -**Arg types:** `(egress?: "permissive" | "strict")` +### `@proxyConfig` +**Value type:** `{egress?: "permissive" | "strict"}` -Configures [credential proxy](/guides/proxy/) egress. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**: it exists only to change the egress mode from the default. +Configures [credential proxy](/guides/proxy/) egress. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**: it exists only to change the egress mode from the default. It is a single-use, object-value decorator (`@proxyConfig={...}`), not a function call. The `egress` option controls requests that don't match any [`@proxy`](#proxy) rule: - `permissive` _(default)_: unmatched hosts pass through untouched. - `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. ```env-spec -# @enableProxy(egress="strict") +# @proxyConfig={egress="strict"} # --- # @proxy(domain="api.openai.com") OPENAI_API_KEY=sk-... diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index dbd73d510..b2c3b5720 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -136,7 +136,7 @@ describe('isCwdWithin (proxy run attach matching)', () => { describe('@proxy nested rules=[...] form', () => { test('desugars to the parent inject rule plus one policy rule per entry (domain written once)', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @proxy(domain="api.stripe.com", rules=[ # {path="/v1/refunds/**", method=[POST, DELETE], block=true}, @@ -163,7 +163,7 @@ describe('@proxy nested rules=[...] form', () => { test('a detached header rules=[...] block inherits the domain and injects nothing', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # @proxy(domain="api.example.com", rules=[{path="/admin/**", block=true}]) # --- FOO=bar @@ -176,7 +176,6 @@ describe('@proxy nested rules=[...] form', () => { test('rejects an entry that tries to re-set domain (injection is the parent rule\'s job)', async () => { const graph = await loadGraph(outdent` - # @enableProxy() # @proxy(domain="api.example.com", rules=[{domain="evil.com", block=true}]) # --- FOO=bar diff --git a/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts index f8f248b07..de1dc78bf 100644 --- a/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts +++ b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts @@ -13,7 +13,7 @@ async function fp(envFile: string): Promise { } const BASE = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @sensitive @proxy(domain="api.x.com", approval=true) SECRET=abc @@ -26,7 +26,7 @@ describe('buildProxySchemaFingerprint', () => { test('decorator order, named-arg order, comments, and whitespace do not affect it', async () => { const reordered = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # a comment that should be ignored # @proxy(approval=true, domain="api.x.com") @sensitive @@ -37,7 +37,7 @@ describe('buildProxySchemaFingerprint', () => { test('an inert decorator (@example) does not change it', async () => { const withExample = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @sensitive @proxy(domain="api.x.com", approval=true) @example=placeholder-ish SECRET=abc @@ -51,13 +51,13 @@ describe('buildProxySchemaFingerprint', () => { test('a single value and a single-element array are identical (domain=a ≡ domain=[a])', async () => { const single = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @sensitive @proxy(domain="api.x.com") SECRET=abc `; const arrayOfOne = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @sensitive @proxy(domain=["api.x.com"]) SECRET=abc @@ -75,7 +75,7 @@ describe('buildProxySchemaFingerprint', () => { test('flipping proxied → passthrough changes it (the gap the old shape-only fingerprint missed)', async () => { const passthrough = outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # --- # @sensitive # @proxy=passthrough diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 02ab3f8f4..51b89bd35 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -128,14 +128,15 @@ export abstract class DecoratorInstance { } if (!this.decoratorDef.isFunction && this.isFunctionCall) { // bare fn-call syntax `@name(...)` is reserved for repeatable decorators (e.g. @docs()). - // @sensitive is single-use but accepts an options-object value — guide users who tried - // to pass options as a bare call toward the object form `@sensitive={...}`. - if (this.name === 'sensitive') { + // @sensitive and @proxyConfig are single-use but accept an options-object value, so guide + // users who tried to pass options as a bare call toward the object form `@name={...}`. + if (this.name === 'sensitive' || this.name === 'proxyConfig') { + const fallback = this.name === 'sensitive' ? '{preventLeaks=false}' : '{egress="strict"}'; const optsStr = this.parsedDecorator.bareFnArgs ? `{${this.parsedDecorator.bareFnArgs.values.map((v) => v.toString()).join(', ')}}` - : '{preventLeaks=false}'; + : fallback; throw new SchemaError( - `@sensitive is single-use and cannot be called like @sensitive(...). To pass options, use an object value: @sensitive=${optsStr}`, + `@${this.name} is single-use and cannot be called like @${this.name}(...). To pass options, use an object value: @${this.name}=${optsStr}`, ); } throw new SchemaError( @@ -544,15 +545,24 @@ export const builtInRootDecorators: Array> = [ name: 'disableProcessEnvInjection', }, { - name: 'enableProxy', - isFunction: true, - useFnArgsResolver: true, - process: (argsVal) => { - const egressResolver = argsVal.objArgs?.egress; + // Single-use header config for the credential proxy. The proxy itself is + // driven by @proxy decorators on items; @proxyConfig only tunes proxy-wide + // settings (currently just egress). Value/object form: @proxyConfig={egress="strict"}. + name: 'proxyConfig', + process: (decValue) => { + if (decValue.objArgs === undefined) { + throw new SchemaError('@proxyConfig must be set to an options object, for example @proxyConfig={egress="strict"}'); + } + for (const key in decValue.objArgs) { + if (key !== 'egress') { + throw new SchemaError(`@proxyConfig: unknown option "${key}" (only "egress" is supported)`); + } + } + const egressResolver = decValue.objArgs.egress; if (!egressResolver || !egressResolver.isStatic) return; const egressValue = egressResolver.staticValue; if (egressValue !== 'permissive' && egressValue !== 'strict') { - throw new SchemaError('@enableProxy: egress must be "permissive" or "strict"'); + throw new SchemaError('@proxyConfig: egress must be "permissive" or "strict"'); } }, }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index a00b1b263..d3cb8c247 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -64,7 +64,6 @@ export type SerializedEnvGraph = { preventLeaks?: boolean; encryptInjectedEnv?: boolean; disableProcessEnvInjection?: boolean; - enableProxy?: boolean; proxyEgress?: ProxyEgressMode; }, config: Record d.resolve())); } @@ -785,13 +784,8 @@ export class EnvGraph { serializedGraph.settings.preventLeaks = this.getRootDec('preventLeaks')?.resolvedValue ?? true; serializedGraph.settings.encryptInjectedEnv = this.getRootDec('encryptInjectedEnv')?.resolvedValue ?? false; serializedGraph.settings.disableProcessEnvInjection = this.getRootDec('disableProcessEnvInjection')?.resolvedValue ?? false; - const enableProxy = this.getRootDecFns('enableProxy')[0]?.resolvedValue; - serializedGraph.settings.enableProxy = !!enableProxy; - if (enableProxy?.obj?.egress === 'strict') { - serializedGraph.settings.proxyEgress = 'strict'; - } else { - serializedGraph.settings.proxyEgress = 'permissive'; - } + const proxyConfig = this.getRootDec('proxyConfig')?.resolvedValue; + serializedGraph.settings.proxyEgress = proxyConfig?.egress === 'strict' ? 'strict' : 'permissive'; // collect all errors into a single nested object const errors: SerializedEnvGraphErrors = {}; diff --git a/packages/varlock/src/env-graph/test/proxy-mode.test.ts b/packages/varlock/src/env-graph/test/proxy-mode.test.ts index 75c0e96ad..8f59b479c 100644 --- a/packages/varlock/src/env-graph/test/proxy-mode.test.ts +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -28,7 +28,7 @@ describe('proxy decorators', () => { test('collects attached and detached proxy rules', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # @proxy(domain="api.example.com") # --- BASELINE=1 @@ -71,7 +71,7 @@ describe('proxy decorators', () => { test('detached rule attaches extra items via keys=[...] array literal', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # @proxy(domain="api.example.com", keys=[STRIPE_KEY, WEBHOOK_SECRET]) # --- # @sensitive @@ -145,7 +145,7 @@ describe('proxy decorators', () => { test('a header-level (detached) @proxy is not rejected as a misplaced item decorator', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # @proxy(domain="api.a.com") # @proxy(domain="api.b.com", path="/admin/**", approval=true) # --- @@ -167,7 +167,7 @@ describe('proxy decorators', () => { test('approval object form: each + maxDuration parse onto the rule', async () => { const graph = await loadGraph(outdent` - # @enableProxy(egress="strict") + # @proxyConfig={egress="strict"} # @proxy(domain="api.a.com", approval=true) # @proxy(domain="api.b.com", approval={each=request, maxDuration="15m"}) # @proxy(domain="api.c.com", approval={each=host, maxDuration=0}) From 15809d92c57590687d357169a2ff36a9eb86603f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 30 Jun 2026 10:24:02 -0700 Subject: [PATCH 50/64] fix(proxy): unbiased session short-id via crypto.randomInt CodeQL js/biased-cryptographic-random flagged mapping crypto random bytes with `% alphabet.length`, which slightly biases the first characters of the short session id. The real session token is the crypto.randomUUID() uuid so impact was low, but crypto.randomInt is uniform and costs nothing. --- packages/varlock/src/proxy/session-registry.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 7148ca81f..5ff89acb6 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -105,10 +105,12 @@ function nowIso(): string { function randomShortId(length = 5): string { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + // crypto.randomInt is uniform; mapping raw random bytes with `% alphabet.length` + // would bias the first few characters. This is a human-friendly handle (the real + // session token is the crypto.randomUUID() `uuid`), but an unbiased id costs nothing. let out = ''; while (out.length < length) { - const byte = crypto.randomBytes(1)[0]!; - out += alphabet[byte % alphabet.length]; + out += alphabet[crypto.randomInt(alphabet.length)]; } return out; } From 5da1fc767024f2ce2958ae9c2ff7c5e2484f1d3e Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 11:03:18 -0700 Subject: [PATCH 51/64] fix(proxy): auto-detect TTY for proxy run stdio so interactive tools work proxy run always piped the child's stdout/stderr for redaction, so interactive tools (claude, psql) saw a non-TTY and dropped into non-interactive mode (claude: "Input must be provided ... when using --print"). It now mirrors varlock run: per-stream auto-detect. A stream attached to an interactive terminal is inherited (raw TTY); only piped/redirected streams go through the redactor. The one-way --no-redact-stdout flag becomes the tri-state --redact-stdout / --no-redact-stdout override (plus _VARLOCK_REDACT_STDOUT), matching run. Dropped the now-unnecessary FORCE_COLOR shim. --- .../content/docs/reference/cli-commands.mdx | 2 +- .../varlock/src/cli/commands/proxy.command.ts | 90 +++++++++++-------- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index b9e3fd2f6..de776c6c5 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -663,7 +663,7 @@ Every subcommand operates on a [session](/guides/proxy/#sessions). Target one wi - `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). - `--allow-reload`: For `start`/`run`, enable live policy hot-reload via `proxy refresh` (off by default; the reload channel is unauthenticated on a shared uid, so only enable behind a sandbox). - `--inject `: For `run`, control what is injected into the child env (default `all`). -- `--no-redact-stdout`: For `run`, disable stdout/stderr redaction (full TTY pass-through). +- `--redact-stdout` / `--no-redact-stdout`: For `run`, override the automatic per-stream redaction. By default output is redacted only when piped or redirected; streams attached to an interactive terminal pass through as a raw TTY, so interactive tools like `claude` work. `--no-redact-stdout` disables redaction entirely; `--redact-stdout` forces it for piped output (and errors on a TTY). Also settable via `_VARLOCK_REDACT_STDOUT`. - `--watch`: For `status`, continuously refresh. - `--format `: Output format for `audit` (text/json) and `env` (shell/json). diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index aa4ed2c58..bd17f7701 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -97,9 +97,9 @@ export const commandSpec = define({ multiple: true, description: 'Path to a specific .env file or directory to use as the entry point (can be specified multiple times)', }, - 'no-redact-stdout': { + 'redact-stdout': { type: 'boolean', - description: 'Disable stdout/stderr redaction and use stdio inherit for full TTY pass-through', + description: 'Override automatic stdout/stderr redaction: --redact-stdout forces redaction of piped/redirected output (e.g., to override @redactLogs=false) and errors if attached to an interactive terminal; --no-redact-stdout disables redaction entirely. Can also be set via the _VARLOCK_REDACT_STDOUT env var (the flag takes precedence)', }, inject: { type: 'string', @@ -156,6 +156,15 @@ const EMPTY_PROXY_SESSION_STATS: ProxySessionStats = { blockedRequests: 0, }; +/** Parse a tri-state on/off/unset env toggle (mirrors the same helper in run.command). */ +function parseEnvToggle(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return true; + if (normalized === '0' || normalized === 'false') return false; + return undefined; +} + function cloneSessionStats(stats?: ProxySessionStats): ProxySessionStats { return { totalRequests: stats?.totalRequests ?? 0, @@ -807,14 +816,40 @@ async function runAction(ctx: any) { let commandProcess: ReturnType | undefined; const redactLogs = policy.serializedGraph.settings?.redactLogs ?? true; - const noRedactStdout = ctx.values['no-redact-stdout'] ?? false; + // tri-state override (true = force on, false = force off, undefined = auto-detect): + // the --redact-stdout / --no-redact-stdout flag wins, then _VARLOCK_REDACT_STDOUT, + // otherwise we auto-detect per stream below (matching `varlock run`). + const redactOverride = ctx.values['redact-stdout'] ?? parseEnvToggle(process.env._VARLOCK_REDACT_STDOUT); + const forceRedact = redactOverride === true; + const forceNoRedact = redactOverride === false; + + // Redacting a TTY-attached stream is impossible without piping it, which breaks + // interactive/TTY tools (claude, psql) - so fail loudly rather than silently degrade. + if (forceRedact && (process.stdout.isTTY || process.stderr.isTTY)) { + throw new CliExitError('Cannot force redaction while output is attached to an interactive terminal', { + details: [ + 'Redaction requires piping stdout/stderr, which breaks tools that need a raw TTY (e.g., claude, psql).', + 'Redaction is applied automatically whenever output is piped or redirected, so you can likely just drop the --redact-stdout flag.', + ], + }); + } - if (redactLogs) { + // Auto-detect per stream: a stream attached to an interactive terminal is inherited + // directly (raw TTY, so interactive children like `claude` work, and the human at the + // terminal already sees the secrets); piped/redirected streams (files, CI, pagers) are + // where leaked output persists, so those get piped through the redactor. + const redactionEnabled = !forceNoRedact && (redactLogs || forceRedact); + const redactStdout = redactionEnabled && (forceRedact || !process.stdout.isTTY); + const redactStderr = redactionEnabled && (forceRedact || !process.stderr.isTTY); + + // Seed the redaction map with the REAL values the proxy injects at the wire (on top of + // the schema's own secrets), so if the child ever echoes an injected value back it is + // still scrubbed. Only needed when we actually redact a stream. + if (redactStdout || redactStderr) { const redactionGraph = { ...policy.serializedGraph, config: { ...policy.serializedGraph.config }, }; - for (const managedItem of policy.proxyManagedItems) { const schemaItem = policy.serializedGraph.config[managedItem.key]; if (!schemaItem?.isSensitive) continue; @@ -824,50 +859,33 @@ async function runAction(ctx: any) { isSensitive: true, }; } - resetRedactionMap(redactionGraph); } - if (noRedactStdout) { + if (!redactStdout && !redactStderr) { + // full stdio inherit - raw TTY pass-through, no redaction needed on any stream commandProcess = exec(rawCommand, commandArgsOnly, { stdio: 'inherit', env: fullInjectedEnv, }); } else { - let redactEnv: NodeJS.ProcessEnv = fullInjectedEnv; - if ( - process.stdout.isTTY - && process.env.NO_COLOR === undefined - && process.env.FORCE_COLOR === undefined - ) { - let forceColorLevel = '1'; - if (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit') { - forceColorLevel = '3'; - } else if (process.env.TERM?.includes('256color') || process.env.TERM_PROGRAM === 'iTerm.app') { - forceColorLevel = '2'; - } - redactEnv = { ...fullInjectedEnv, FORCE_COLOR: forceColorLevel }; - } - commandProcess = exec(rawCommand, commandArgsOnly, { stdin: 'inherit', - stdout: 'pipe', - stderr: 'pipe', - env: redactEnv, + stdout: redactStdout ? 'pipe' : 'inherit', + stderr: redactStderr ? 'pipe' : 'inherit', + env: fullInjectedEnv, }); - // Pipe child output through the shared chunk-boundary-buffered redactor (the - // same one `varlock run` uses) so a secret split across two chunks is still - // caught — the proxy must redact the real values it injects at the wire. - if (redactLogs) { + // Pipe redacted streams through the shared chunk-boundary-buffered redactor (the same + // one `varlock run` uses) so a secret split across two chunks is still caught. + if (redactStdout && commandProcess.stdout) { const stdoutWriter = createRedactedStreamWriter(process.stdout); + commandProcess.stdout.on('data', stdoutWriter.write); + commandProcess.stdout.on('close', stdoutWriter.flush); + } + if (redactStderr && commandProcess.stderr) { const stderrWriter = createRedactedStreamWriter(process.stderr); - commandProcess.stdout?.on('data', stdoutWriter.write); - commandProcess.stdout?.on('close', stdoutWriter.flush); - commandProcess.stderr?.on('data', stderrWriter.write); - commandProcess.stderr?.on('close', stderrWriter.flush); - } else { - commandProcess.stdout?.on('data', (chunk: Buffer | string) => process.stdout.write(chunk)); - commandProcess.stderr?.on('data', (chunk: Buffer | string) => process.stderr.write(chunk)); + commandProcess.stderr.on('data', stderrWriter.write); + commandProcess.stderr.on('close', stderrWriter.flush); } } From 39a5c2e6aa1c738f8cea1d2d3cf311b06695c549 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 11:15:53 -0700 Subject: [PATCH 52/64] refactor(cli): share stdout-redaction logic between run and proxy run The per-stream TTY auto-detect and the --redact-stdout override were duplicated in run.command and proxy.command, and had already drifted: the proxy copy of the flag was missing `negatable: true`, so --no-redact-stdout was rejected there. Extracted the decision (resolveStdoutRedaction), the child-stream wiring (pipeRedactedStreams), the env-toggle parser, and the flag spec (REDACT_STDOUT_ARG) into cli/helpers/stdout-redaction.ts, now used by both commands. Added a unit test for the previously-untested decision logic. Behavior verified unchanged on the compiled binary (piped output redacts, interactive TTY inherits) for run and proxy run alike. --- .../varlock/src/cli/commands/proxy.command.ts | 84 ++++------------ .../varlock/src/cli/commands/run.command.ts | 67 ++----------- .../src/cli/helpers/stdout-redaction.ts | 86 +++++++++++++++++ .../cli/helpers/test/stdout-redaction.test.ts | 96 +++++++++++++++++++ 4 files changed, 207 insertions(+), 126 deletions(-) create mode 100644 packages/varlock/src/cli/helpers/stdout-redaction.ts create mode 100644 packages/varlock/src/cli/helpers/test/stdout-redaction.test.ts diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index bd17f7701..b671b3a23 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -55,7 +55,7 @@ import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; import { resetRedactionMap } from '../../runtime/env'; -import { createRedactedStreamWriter } from '../../runtime/lib/redact-stream'; +import { REDACT_STDOUT_ARG, resolveStdoutRedaction, pipeRedactedStreams } from '../helpers/stdout-redaction'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; import { @@ -97,10 +97,7 @@ export const commandSpec = define({ multiple: true, description: 'Path to a specific .env file or directory to use as the entry point (can be specified multiple times)', }, - 'redact-stdout': { - type: 'boolean', - description: 'Override automatic stdout/stderr redaction: --redact-stdout forces redaction of piped/redirected output (e.g., to override @redactLogs=false) and errors if attached to an interactive terminal; --no-redact-stdout disables redaction entirely. Can also be set via the _VARLOCK_REDACT_STDOUT env var (the flag takes precedence)', - }, + ...REDACT_STDOUT_ARG, inject: { type: 'string', short: 'i', @@ -156,15 +153,6 @@ const EMPTY_PROXY_SESSION_STATS: ProxySessionStats = { blockedRequests: 0, }; -/** Parse a tri-state on/off/unset env toggle (mirrors the same helper in run.command). */ -function parseEnvToggle(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === '1' || normalized === 'true') return true; - if (normalized === '0' || normalized === 'false') return false; - return undefined; -} - function cloneSessionStats(stats?: ProxySessionStats): ProxySessionStats { return { totalRequests: stats?.totalRequests ?? 0, @@ -813,34 +801,12 @@ async function runAction(ctx: any) { fullInjectedEnv._VARLOCK_ENV_KEY = process.env._VARLOCK_ENV_KEY; } - let commandProcess: ReturnType | undefined; - - const redactLogs = policy.serializedGraph.settings?.redactLogs ?? true; - // tri-state override (true = force on, false = force off, undefined = auto-detect): - // the --redact-stdout / --no-redact-stdout flag wins, then _VARLOCK_REDACT_STDOUT, - // otherwise we auto-detect per stream below (matching `varlock run`). - const redactOverride = ctx.values['redact-stdout'] ?? parseEnvToggle(process.env._VARLOCK_REDACT_STDOUT); - const forceRedact = redactOverride === true; - const forceNoRedact = redactOverride === false; - - // Redacting a TTY-attached stream is impossible without piping it, which breaks - // interactive/TTY tools (claude, psql) - so fail loudly rather than silently degrade. - if (forceRedact && (process.stdout.isTTY || process.stderr.isTTY)) { - throw new CliExitError('Cannot force redaction while output is attached to an interactive terminal', { - details: [ - 'Redaction requires piping stdout/stderr, which breaks tools that need a raw TTY (e.g., claude, psql).', - 'Redaction is applied automatically whenever output is piped or redirected, so you can likely just drop the --redact-stdout flag.', - ], - }); - } - - // Auto-detect per stream: a stream attached to an interactive terminal is inherited - // directly (raw TTY, so interactive children like `claude` work, and the human at the - // terminal already sees the secrets); piped/redirected streams (files, CI, pagers) are - // where leaked output persists, so those get piped through the redactor. - const redactionEnabled = !forceNoRedact && (redactLogs || forceRedact); - const redactStdout = redactionEnabled && (forceRedact || !process.stdout.isTTY); - const redactStderr = redactionEnabled && (forceRedact || !process.stderr.isTTY); + // Per-stream TTY auto-detect (interactive terminal -> raw inherit so tools like `claude` + // work; piped/redirected -> redact). Shared with `varlock run` so they can't diverge. + const { redactStdout, redactStderr } = resolveStdoutRedaction({ + redactStdoutFlag: ctx.values['redact-stdout'], + redactLogs: policy.serializedGraph.settings?.redactLogs ?? true, + }); // Seed the redaction map with the REAL values the proxy injects at the wire (on top of // the schema's own secrets), so if the child ever echoes an injected value back it is @@ -862,32 +828,14 @@ async function runAction(ctx: any) { resetRedactionMap(redactionGraph); } - if (!redactStdout && !redactStderr) { - // full stdio inherit - raw TTY pass-through, no redaction needed on any stream - commandProcess = exec(rawCommand, commandArgsOnly, { - stdio: 'inherit', - env: fullInjectedEnv, - }); - } else { - commandProcess = exec(rawCommand, commandArgsOnly, { - stdin: 'inherit', - stdout: redactStdout ? 'pipe' : 'inherit', - stderr: redactStderr ? 'pipe' : 'inherit', - env: fullInjectedEnv, - }); - // Pipe redacted streams through the shared chunk-boundary-buffered redactor (the same - // one `varlock run` uses) so a secret split across two chunks is still caught. - if (redactStdout && commandProcess.stdout) { - const stdoutWriter = createRedactedStreamWriter(process.stdout); - commandProcess.stdout.on('data', stdoutWriter.write); - commandProcess.stdout.on('close', stdoutWriter.flush); - } - if (redactStderr && commandProcess.stderr) { - const stderrWriter = createRedactedStreamWriter(process.stderr); - commandProcess.stderr.on('data', stderrWriter.write); - commandProcess.stderr.on('close', stderrWriter.flush); - } - } + // An inherited stream (both false) yields raw TTY pass-through, same as stdio: 'inherit'. + const commandProcess = exec(rawCommand, commandArgsOnly, { + stdin: 'inherit', + stdout: redactStdout ? 'pipe' : 'inherit', + stderr: redactStderr ? 'pipe' : 'inherit', + env: fullInjectedEnv, + }); + pipeRedactedStreams(commandProcess, { redactStdout, redactStderr }); if (commandProcess.pid && !attachSession) { await updateProxySessionRecord(session.uuid, { childPid: commandProcess.pid }); diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 5b18be7b3..ce91a2ef9 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -7,6 +7,7 @@ import { loadVarlockEnvGraph } from '../../lib/load-graph'; import { checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors } from '../helpers/error-checks'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; +import { REDACT_STDOUT_ARG, resolveStdoutRedaction, pipeRedactedStreams } from '../helpers/stdout-redaction'; export const commandSpec = define({ name: 'run', @@ -17,11 +18,7 @@ export const commandSpec = define({ // short: 'w', // description: 'Watch mode', // }, - 'redact-stdout': { - type: 'boolean', - negatable: true, - description: 'Override automatic stdout/stderr redaction: --redact-stdout forces redaction of piped/redirected output (e.g., to override @redactLogs=false) and errors if attached to an interactive terminal; --no-redact-stdout disables redaction entirely. Can also be set via the _VARLOCK_REDACT_STDOUT env var (the flag takes precedence)', - }, + ...REDACT_STDOUT_ARG, inject: { type: 'string', short: 'i', @@ -77,19 +74,6 @@ Examples: `.trim(), }); -/** - * Parse a tri-state boolean toggle from an env var value. - * Returns true/false for recognized truthy/falsy values, or undefined when unset - * or unrecognized (so it can fall through to other logic via `??`). - */ -function parseEnvToggle(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (normalized === '1' || normalized === 'true') return true; - if (normalized === '0' || normalized === 'false') return false; - return undefined; -} - let commandProcess: ReturnType | undefined; let childCommandKilledFromRestart = false; const isWatchModeRestart = false; // TODO: re-enable watch mode @@ -208,7 +192,6 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const resolvedEnv = envGraph.getResolvedEnvObject({ includeInternal }); const serializedGraph = envGraph.getSerializedGraph(); const { resetRedactionMap } = await import('../../runtime/env'); - const { createRedactedStreamWriter } = await import('../../runtime/lib/redact-stream'); // console.log(resolvedEnv); // handle deprecated --no-inject-graph flag @@ -247,33 +230,12 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = fullInjectedEnv._VARLOCK_ENV_KEY = process.env._VARLOCK_ENV_KEY; } - const redactLogs = serializedGraph.settings?.redactLogs ?? true; - // tri-state override (true = force on, false = force off, undefined = auto-detect): - // the --redact-stdout / --no-redact-stdout flag takes precedence, falling back to the - // _VARLOCK_REDACT_STDOUT env var, otherwise we auto-detect per stream below - const redactOverride = ctx.values['redact-stdout'] ?? parseEnvToggle(process.env._VARLOCK_REDACT_STDOUT); - const forceRedact = redactOverride === true; - const forceNoRedact = redactOverride === false; - - // redacting a TTY-attached stream is not possible without piping it, which breaks - // interactive/TTY-dependent tools - so we fail loudly rather than silently degrade - if (forceRedact && (process.stdout.isTTY || process.stderr.isTTY)) { - throw new CliExitError('Cannot force redaction while output is attached to an interactive terminal', { - details: [ - 'Redaction requires piping stdout/stderr, which breaks tools that need a raw TTY (e.g., claude, psql).', - 'Redaction is applied automatically whenever output is piped or redirected, so you can likely just drop the --redact-stdout flag.', - ], - }); - } - - // Explicit flags force redaction on/off; otherwise we auto-detect per stream: - // streams attached to an interactive terminal are inherited directly, preserving raw TTY - // behavior for interactive tools (psql, claude, etc.) — a human at the terminal already - // has access to the secrets. Piped/redirected streams (CI logs, files, pipes) are where - // leaked output persists, so those are piped through redaction. - const redactionEnabled = !forceNoRedact && (redactLogs || forceRedact); - const redactStdout = redactionEnabled && (forceRedact || !process.stdout.isTTY); - const redactStderr = redactionEnabled && (forceRedact || !process.stderr.isTTY); + // Per-stream TTY auto-detect (interactive terminal -> raw inherit; piped/redirected -> + // redact). Shared with `varlock proxy run` so the two commands can't diverge. + const { redactStdout, redactStderr } = resolveStdoutRedaction({ + redactStdoutFlag: ctx.values['redact-stdout'], + redactLogs: serializedGraph.settings?.redactLogs ?? true, + }); // Run the child in its own process group (setsid) only when NO std stream is a TTY — // i.e. containers, CI, and background/agent invocations. Detaching is what lets us @@ -345,18 +307,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = detached: useProcessGroup, }); - // pipe output through redaction writers, which buffer possible partial secrets - // at chunk boundaries so split secrets are still redacted - if (redactStdout && commandProcess.stdout) { - const stdoutWriter = createRedactedStreamWriter(process.stdout); - commandProcess.stdout.on('data', stdoutWriter.write); - commandProcess.stdout.on('close', stdoutWriter.flush); - } - if (redactStderr && commandProcess.stderr) { - const stderrWriter = createRedactedStreamWriter(process.stderr); - commandProcess.stderr.on('data', stderrWriter.write); - commandProcess.stderr.on('close', stderrWriter.flush); - } + pipeRedactedStreams(commandProcess, { redactStdout, redactStderr }); } // console.log('PARENT PID = ', process.pid); // console.log('CHILD PID = ', commandProcess.pid); diff --git a/packages/varlock/src/cli/helpers/stdout-redaction.ts b/packages/varlock/src/cli/helpers/stdout-redaction.ts new file mode 100644 index 000000000..3d9edbec2 --- /dev/null +++ b/packages/varlock/src/cli/helpers/stdout-redaction.ts @@ -0,0 +1,86 @@ +import { CliExitError } from './exit-error'; +import { createRedactedStreamWriter } from '../../runtime/lib/redact-stream'; + +/** + * Shared gunshi arg spec for the redaction override, so `varlock run` and + * `varlock proxy run` expose an identical flag (including `--no-redact-stdout`, + * which only works because of `negatable`). + */ +export const REDACT_STDOUT_ARG = { + 'redact-stdout': { + type: 'boolean', + negatable: true, + description: 'Override automatic stdout/stderr redaction: --redact-stdout forces redaction of piped/redirected output (e.g., to override @redactLogs=false) and errors if attached to an interactive terminal; --no-redact-stdout disables redaction entirely. Can also be set via the _VARLOCK_REDACT_STDOUT env var (the flag takes precedence)', + }, +} as const; + +/** Parse a tri-state on/off/unset env toggle (e.g. `_VARLOCK_REDACT_STDOUT`). */ +export function parseEnvToggle(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return true; + if (normalized === '0' || normalized === 'false') return false; + return undefined; +} + +export type StdoutRedactionPlan = { redactStdout: boolean; redactStderr: boolean }; + +/** + * Decide, per stream, whether a spawned child's stdout/stderr should be piped through the + * redactor or inherited raw. Shared by `varlock run` and `varlock proxy run` so the two + * commands cannot diverge. + * + * A stream attached to an interactive terminal is inherited (raw TTY, so interactive tools + * like `claude`/`psql` work, and a human at the terminal already sees the secrets). Piped or + * redirected streams (CI logs, files, pagers) are where leaked output persists, so those are + * routed through the redactor. + * + * The `--redact-stdout` / `--no-redact-stdout` flag, then `_VARLOCK_REDACT_STDOUT`, overrides + * the auto-detect. Forcing redaction onto a TTY is impossible without a PTY, so we throw. + */ +export function resolveStdoutRedaction(opts: { + redactStdoutFlag: boolean | undefined; + redactLogs: boolean; +}): StdoutRedactionPlan { + const redactOverride = opts.redactStdoutFlag ?? parseEnvToggle(process.env._VARLOCK_REDACT_STDOUT); + const forceRedact = redactOverride === true; + const forceNoRedact = redactOverride === false; + + // Redacting a TTY-attached stream is impossible without piping it, which breaks + // interactive/TTY tools (claude, psql) - so fail loudly rather than silently degrade. + if (forceRedact && (process.stdout.isTTY || process.stderr.isTTY)) { + throw new CliExitError('Cannot force redaction while output is attached to an interactive terminal', { + details: [ + 'Redaction requires piping stdout/stderr, which breaks tools that need a raw TTY (e.g., claude, psql).', + 'Redaction is applied automatically whenever output is piped or redirected, so you can likely just drop the --redact-stdout flag.', + ], + }); + } + + const redactionEnabled = !forceNoRedact && (opts.redactLogs || forceRedact); + return { + redactStdout: redactionEnabled && (forceRedact || !process.stdout.isTTY), + redactStderr: redactionEnabled && (forceRedact || !process.stderr.isTTY), + }; +} + +/** + * Wire the shared chunk-boundary-buffered redactor onto whichever of the child's streams the + * plan marks for redaction (so a secret split across two chunks is still caught). No-ops for + * any stream that is inherited, since there is no pipe to read. + */ +export function pipeRedactedStreams( + commandProcess: { stdout?: NodeJS.ReadableStream | null; stderr?: NodeJS.ReadableStream | null }, + plan: StdoutRedactionPlan, +): void { + if (plan.redactStdout && commandProcess.stdout) { + const writer = createRedactedStreamWriter(process.stdout); + commandProcess.stdout.on('data', writer.write); + commandProcess.stdout.on('close', writer.flush); + } + if (plan.redactStderr && commandProcess.stderr) { + const writer = createRedactedStreamWriter(process.stderr); + commandProcess.stderr.on('data', writer.write); + commandProcess.stderr.on('close', writer.flush); + } +} diff --git a/packages/varlock/src/cli/helpers/test/stdout-redaction.test.ts b/packages/varlock/src/cli/helpers/test/stdout-redaction.test.ts new file mode 100644 index 000000000..551fbeb39 --- /dev/null +++ b/packages/varlock/src/cli/helpers/test/stdout-redaction.test.ts @@ -0,0 +1,96 @@ +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; + +import { parseEnvToggle, resolveStdoutRedaction } from '../stdout-redaction'; + +// resolveStdoutRedaction reads process.stdout.isTTY / process.stderr.isTTY and the +// _VARLOCK_REDACT_STDOUT env var, so we stub those and restore them after each test. +let origStdoutTTY: unknown; +let origStderrTTY: unknown; +let origEnv: string | undefined; + +function setTTY(stdout: boolean, stderr: boolean) { + (process.stdout as any).isTTY = stdout; + (process.stderr as any).isTTY = stderr; +} + +beforeEach(() => { + origStdoutTTY = (process.stdout as any).isTTY; + origStderrTTY = (process.stderr as any).isTTY; + origEnv = process.env._VARLOCK_REDACT_STDOUT; + delete process.env._VARLOCK_REDACT_STDOUT; +}); + +afterEach(() => { + (process.stdout as any).isTTY = origStdoutTTY; + (process.stderr as any).isTTY = origStderrTTY; + if (origEnv === undefined) delete process.env._VARLOCK_REDACT_STDOUT; + else process.env._VARLOCK_REDACT_STDOUT = origEnv; +}); + +describe('parseEnvToggle', () => { + test('true-ish / false-ish / unset', () => { + expect(parseEnvToggle('1')).toBe(true); + expect(parseEnvToggle('true')).toBe(true); + expect(parseEnvToggle(' TRUE ')).toBe(true); + expect(parseEnvToggle('0')).toBe(false); + expect(parseEnvToggle('false')).toBe(false); + expect(parseEnvToggle(undefined)).toBeUndefined(); + expect(parseEnvToggle('maybe')).toBeUndefined(); + }); +}); + +describe('resolveStdoutRedaction auto-detect (no override)', () => { + test('piped streams are redacted, TTY streams are inherited (per stream)', () => { + setTTY(false, false); + expect(resolveStdoutRedaction({ redactStdoutFlag: undefined, redactLogs: true })) + .toEqual({ redactStdout: true, redactStderr: true }); + + setTTY(true, true); + expect(resolveStdoutRedaction({ redactStdoutFlag: undefined, redactLogs: true })) + .toEqual({ redactStdout: false, redactStderr: false }); + + // mixed: interactive stdout, captured stderr + setTTY(true, false); + expect(resolveStdoutRedaction({ redactStdoutFlag: undefined, redactLogs: true })) + .toEqual({ redactStdout: false, redactStderr: true }); + }); + + test('@redactLogs=false disables auto redaction even when piped', () => { + setTTY(false, false); + expect(resolveStdoutRedaction({ redactStdoutFlag: undefined, redactLogs: false })) + .toEqual({ redactStdout: false, redactStderr: false }); + }); +}); + +describe('resolveStdoutRedaction overrides', () => { + test('--no-redact-stdout (false) disables redaction on piped output', () => { + setTTY(false, false); + expect(resolveStdoutRedaction({ redactStdoutFlag: false, redactLogs: true })) + .toEqual({ redactStdout: false, redactStderr: false }); + }); + + test('--redact-stdout (true) forces redaction, overriding @redactLogs=false', () => { + setTTY(false, false); + expect(resolveStdoutRedaction({ redactStdoutFlag: true, redactLogs: false })) + .toEqual({ redactStdout: true, redactStderr: true }); + }); + + test('--redact-stdout errors when any stream is a TTY (cannot redact a raw TTY)', () => { + setTTY(true, false); + expect(() => resolveStdoutRedaction({ redactStdoutFlag: true, redactLogs: true })) + .toThrow(/interactive terminal/i); + }); + + test('_VARLOCK_REDACT_STDOUT is honored, but the flag takes precedence', () => { + setTTY(false, false); + process.env._VARLOCK_REDACT_STDOUT = '0'; + // env says off + expect(resolveStdoutRedaction({ redactStdoutFlag: undefined, redactLogs: true })) + .toEqual({ redactStdout: false, redactStderr: false }); + // explicit flag beats the env var + expect(resolveStdoutRedaction({ redactStdoutFlag: true, redactLogs: true })) + .toEqual({ redactStdout: true, redactStderr: true }); + }); +}); From 78c3fbd0218a794c26c16afdbb6ebee78bc6a517 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 17:38:10 -0700 Subject: [PATCH 53/64] docs(proxy): note to use ANTHROPIC_API_KEY for Claude Code, not an OAuth token The proxy guide demos `proxy run -- claude` but never says which credential to wire. CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) only works for headless `claude -p` requests; the interactive TUI 401s regardless of egress. Add a callout in the proxy guide and a one-liner in the ai-tools Claude tab pointing at ANTHROPIC_API_KEY. --- packages/varlock-website/src/content/docs/guides/ai-tools.mdx | 2 +- packages/varlock-website/src/content/docs/guides/proxy.mdx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/varlock-website/src/content/docs/guides/ai-tools.mdx b/packages/varlock-website/src/content/docs/guides/ai-tools.mdx index 20c47773c..f7bb00db5 100644 --- a/packages/varlock-website/src/content/docs/guides/ai-tools.mdx +++ b/packages/varlock-website/src/content/docs/guides/ai-tools.mdx @@ -66,7 +66,7 @@ Varlock does not auto-load a global schema (that would make two developers' mach [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) is Anthropic's CLI tool for AI-assisted coding. - **Environment variable:** `ANTHROPIC_API_KEY` — see [supported env variables](https://docs.claude.com/en/docs/claude-code/settings#environment-variables). + **Environment variable:** `ANTHROPIC_API_KEY` (see [supported env variables](https://docs.claude.com/en/docs/claude-code/settings#environment-variables)). Use the API key, not `CLAUDE_CODE_OAUTH_TOKEN`: that (from `claude setup-token`) only works for headless `claude -p` requests, not the interactive TUI. **In a project** — add to `.env.schema`: diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 48df580e3..8931b57c3 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -123,6 +123,10 @@ varlock proxy run -- python tool.py That's it. The child inherits everything it needs (proxy address + CA trust) automatically. +:::note[Using the proxy with Claude Code] +Wire Claude Code's credential as [`ANTHROPIC_API_KEY`](/guides/ai-tools/) (an `sk-ant-api…` key). `CLAUDE_CODE_OAUTH_TOKEN` (a subscription token from `claude setup-token`) only works for headless `claude -p` requests: the interactive TUI rejects a static token, so through the proxy it fails with a `401` regardless of your egress setting. +::: + :::caution[Put `@proxy` on the item, not in the header] `@proxy(domain=...)` on a **config item** routes _that item's_ secret to the domain. The same decorator in the **header** creates a _detached_ policy rule for a domain with **no** secret injection (useful for `block` rules). A common mistake is to write `@proxy` in the header and expect a separate item to be injected, but it won't be. See [Routing rules](#routing-rules). ::: From 3186f23f497ff17dbbe3ccb04cc17bdbb2d8b2ab Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 2 Jul 2026 17:38:35 -0700 Subject: [PATCH 54/64] feat(proxy): configurable reload posture + refuse agent-triggered reloads; rename refresh to reload Three related changes to the credential proxy's schema-reload feature: - Rename the `proxy refresh` command to `proxy reload`, matching the --allow-reload flag and the reload-channel internals (already "reload"). - Refuse a reload requested from inside the proxied agent: the daemon returns a "denied" result, logs the attempt to the `proxy start` output so a watching human sees it, and the agent's `proxy reload` explains it must be applied from a trusted terminal. Stops an agent self-approving its own schema edit. - Make reload posture configurable and adaptive: @proxyConfig={reload=off|manual|auto} (default auto) plus a negatable --allow-reload/--no-allow-reload override. `auto` resolves at launch from the launcher's context (manual for an interactive `proxy start`, off for headless or one-shot `proxy run`), is agent-resistant, and only widens toward agent-triggered reload when safe (TODO sandbox-detect / approval seams). --- .bumpy/proxy-hot-reload.md | 7 + .bumpy/proxy-refresh-hot-reload.md | 7 - .../src/content/docs/guides/proxy.mdx | 10 +- .../content/docs/reference/cli-commands.mdx | 4 +- .../docs/reference/root-decorators.mdx | 13 +- .../varlock/src/cli/commands/proxy.command.ts | 168 ++++++++++++++---- .../cli/commands/test/proxy.command.test.ts | 43 ++++- .../src/cli/helpers/proxy-context-guard.ts | 4 +- .../helpers/test/proxy-context-guard.test.ts | 2 +- .../varlock/src/env-graph/lib/decorators.ts | 20 ++- .../varlock/src/env-graph/lib/env-graph.ts | 5 + .../varlock/src/proxy/reload-channel.test.ts | 16 ++ packages/varlock/src/proxy/reload-channel.ts | 20 ++- packages/varlock/src/proxy/runtime-proxy.ts | 2 +- .../varlock/src/proxy/session-registry.ts | 2 +- 15 files changed, 250 insertions(+), 73 deletions(-) create mode 100644 .bumpy/proxy-hot-reload.md delete mode 100644 .bumpy/proxy-refresh-hot-reload.md diff --git a/.bumpy/proxy-hot-reload.md b/.bumpy/proxy-hot-reload.md new file mode 100644 index 000000000..d201b92b8 --- /dev/null +++ b/.bumpy/proxy-hot-reload.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`varlock proxy reload` hot-reloads a running proxy without a restart. + +After editing your schema, run `varlock proxy reload` from a trusted terminal to re-resolve it in the proxy's trusted context and swap the live policy (rules, injected secrets, egress mode) without restarting or dropping your agent's connection. A reload requested from inside the proxied agent is refused and logged, so an agent can't self-approve its own schema edit. Set the posture with `@proxyConfig={reload="off"|"manual"|"auto"}` (default `auto`, which enables it only for an interactive `proxy start`) or override per run with `--allow-reload` / `--no-allow-reload`. On a shared uid this is a bar-raiser, not a hard boundary, so pair it with a sandbox. diff --git a/.bumpy/proxy-refresh-hot-reload.md b/.bumpy/proxy-refresh-hot-reload.md deleted file mode 100644 index 7d770e257..000000000 --- a/.bumpy/proxy-refresh-hot-reload.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -`varlock proxy refresh` hot-reloads a running proxy (opt-in). - -Start a daemon with `varlock proxy start --allow-reload`, then editing your schema and running `varlock proxy refresh` re-resolves it in the proxy's trusted context and swaps the live policy — rules, injected secrets, and egress mode — without restarting the proxy or dropping your agent's connection. It is **off by default**: the reload channel is unauthenticated on a shared uid, so without this gate a same-uid agent could trigger a refresh to self-approve its own schema edit. When disabled, restart the proxy to apply schema changes. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 8931b57c3..36a283815 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -274,7 +274,7 @@ Every `varlock proxy` command operates on a **session**: one running proxy with - **By id:** pass `--session ` to any command (`proxy env --session abc12`, `proxy stop --session abc12`, etc.). - **Auto-discovered:** with no `--session`, the command resolves one for you: - `proxy run` attaches to the running `proxy start` daemon **for the current directory** (a session whose directory is this one or a parent of it). If there isn't one it starts its own proxy; pass `--new` to always start a fresh, separate one. - - `env` / `status` / `audit` / `refresh` / `stop` use the **single active session**. If more than one is running they ask you to pass `--session `; commands run from inside a proxied child target that child's own session automatically. + - `env` / `status` / `audit` / `reload` / `stop` use the **single active session**. If more than one is running they ask you to pass `--session `; commands run from inside a proxied child target that child's own session automatically. Sessions are durable records: they persist after the proxy stops (visible with `proxy status --all`) so their [audit log](#auditing) stays available. @@ -320,8 +320,10 @@ varlock proxy start # or: varlock proxy run -- A one-shot `varlock proxy run` already re-reads the schema on its next invocation, so just re-run it. -:::note[Hot-reload is opt-in] -You can start a daemon with `varlock proxy start --allow-reload` to enable `varlock proxy refresh`, which re-resolves the schema and swaps the live policy without restarting. It's **off by default**: the reload channel is unauthenticated on a shared uid, so a same-uid agent could trigger a refresh to self-approve its own schema edit. Only enable it behind a sandbox (or once a real out-of-band approver ships). +:::note[Hot-reload] +A running proxy can hot-reload your schema without a restart: run `varlock proxy reload` from a **trusted terminal** and it re-resolves the schema and swaps the live policy. A reload requested from **inside** the proxied agent is refused (so the agent can't self-approve its own schema edit) and the attempt is surfaced in the `proxy start` log. + +Whether reload is available is set by [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) (`off` / `manual` / `auto`, default `auto`), and can be overridden per run with `--allow-reload` / `--no-allow-reload`. `auto` is conservative: it enables `manual` reload only for an interactive `varlock proxy start` (a human is there to apply it and watch the log) and stays `off` for headless runs or a one-shot `varlock proxy run`. On a shared uid the agent-refusal is a bar-raiser, not a hard boundary (a marker-stripping agent evades it), so `auto` only widens toward agent-triggered reload once containment (a sandbox) or a real out-of-band approver makes it safe. ::: ## Limitations @@ -332,7 +334,7 @@ The proxy is a local, same-machine tool. Know what it does and doesn't cover bef - **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed**: the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected. - **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream, by design. - **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. -- **Hot-reload is unauthenticated (and off by default).** `proxy refresh` only works when the daemon was started with `--allow-reload`. Even then, any process running as your user can trigger it, so on a shared uid it is not a trust boundary; a real out-of-band approver is planned to close it. +- **Hot-reload is human-applied (and off by default).** `proxy reload` only works when the daemon was started with `--allow-reload`, and a reload requested from inside the proxied agent is refused and logged so the agent can't self-approve a schema edit. But that refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, enable it only behind a sandbox. - **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won't get a working secret. ## Reference diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index de776c6c5..5c04cd625 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -654,14 +654,14 @@ Every subcommand operates on a [session](/guides/proxy/#sessions). Target one wi - `env`: Print the proxy + CA environment for a session, to source into another shell (`eval "$(varlock proxy env)"`). - `status`: List active proxy sessions. - `audit`: Print a session's request audit log (no secret values). -- `refresh`: Hot-reload a daemon started with `--allow-reload` after an intentional schema edit; re-resolves and swaps the live policy without restarting. (Off by default; otherwise restart the proxy to apply schema changes.) +- `reload`: Re-resolve the schema and swap a running proxy's live policy without restarting, after an intentional schema edit. Must be run from a **trusted terminal**: a reload requested from inside the proxied agent is refused and logged. Requires the proxy's reload posture to allow it (see `--allow-reload` / [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig)); otherwise restart the proxy to apply schema changes. - `stop`: Stop a session. **Options:** - `--session `: Target a specific session by id. - `--new`: For `run`, force a fresh proxy instead of attaching to a running one. - `--all`: For `status`/`stop`, include all sessions (`status` also shows ended sessions). -- `--allow-reload`: For `start`/`run`, enable live policy hot-reload via `proxy refresh` (off by default; the reload channel is unauthenticated on a shared uid, so only enable behind a sandbox). +- `--allow-reload` / `--no-allow-reload`: For `start`/`run`, override the reload posture. `--allow-reload` forces `manual` (human-applied from a trusted terminal; agent-context reloads refused), `--no-allow-reload` forces `off`. Otherwise [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) applies, defaulting to `auto` (manual for an interactive `proxy start`, off for headless or one-shot `proxy run`). On a shared uid this is a bar-raiser, not a hard boundary, so prefer a sandbox. - `--inject `: For `run`, control what is injected into the child env (default `all`). - `--redact-stdout` / `--no-redact-stdout`: For `run`, override the automatic per-stream redaction. By default output is redacted only when piped or redirected; streams attached to an interactive terminal pass through as a raw TTY, so interactive tools like `claude` work. `--no-redact-stdout` disables redaction entirely; `--redact-stdout` forces it for piped output (and errors on a TTY). Also settable via `_VARLOCK_REDACT_STDOUT`. - `--watch`: For `status`, continuously refresh. diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index a0c6f8b1d..10e13c970 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -487,16 +487,21 @@ API_KEY=
### `@proxyConfig` -**Value type:** `{egress?: "permissive" | "strict"}` +**Value type:** `{egress?: "permissive" | "strict", reload?: "off" | "manual" | "auto"}` -Configures [credential proxy](/guides/proxy/) egress. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**: it exists only to change the egress mode from the default. It is a single-use, object-value decorator (`@proxyConfig={...}`), not a function call. +Configures proxy-wide [credential proxy](/guides/proxy/) settings. The proxy is driven by [`@proxy`](/reference/item-decorators/#proxy) decorators on your items, so this header decorator is **optional**. It is a single-use, object-value decorator (`@proxyConfig={...}`), not a function call. -The `egress` option controls requests that don't match any [`@proxy`](#proxy) rule: +`egress` controls requests that don't match any [`@proxy`](#proxy) rule: - `permissive` _(default)_: unmatched hosts pass through untouched. - `strict`: only requests matching a `@proxy` rule are allowed; everything else is blocked. +`reload` controls live [hot-reload](/guides/proxy/#editing-the-schema-while-a-session-is-running) of the running proxy after a schema edit: +- `off`: no reload; restart the proxy to pick up schema changes. +- `manual`: a human applies it by running `varlock proxy reload` from a trusted terminal; a reload requested from inside the proxied agent is refused and logged. +- `auto` _(default)_: resolved at launch. Conservative today: `manual` for an interactive `varlock proxy start`, `off` for headless runs or a one-shot `varlock proxy run`. It only widens (toward agent-triggered reload behind an approver) when that becomes safe, e.g. inside a sandbox. The `--allow-reload` / `--no-allow-reload` flag overrides it per run. + ```env-spec -# @proxyConfig={egress="strict"} +# @proxyConfig={egress="strict", reload="manual"} # --- # @proxy(domain="api.openai.com") OPENAI_API_KEY=sk-... diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index b671b3a23..085ffe464 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -49,8 +49,10 @@ import { readReloadResult, writeReloadRequest, writeReloadResult, + type ProxyReloadRequest, type ProxyReloadResult, } from '../../proxy/reload-channel'; +import { isInProxyContext } from '../helpers/proxy-context-guard'; import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; @@ -109,9 +111,11 @@ export const commandSpec = define({ }, 'allow-reload': { type: 'boolean', - description: 'For `proxy start`/`run`: enable live policy hot-reload via `proxy refresh`. Off by default; ' - + 'the reload channel is unauthenticated on a shared uid, so a same-uid agent could self-approve a schema ' - + 'edit. Only enable it alongside a sandbox (or once the out-of-band approver ships).', + negatable: true, + description: 'Override the reload posture for this `proxy start`/`run`: `--allow-reload` forces `manual` ' + + '(human-applied from a trusted terminal; reloads requested from inside the agent are refused), ' + + '`--no-allow-reload` forces `off`. Otherwise `@proxyConfig={reload=...}` applies, defaulting to `auto` ' + + '(manual for an interactive `proxy start`, off for headless or one-shot `proxy run`).', }, }, examples: ` @@ -124,7 +128,7 @@ Proxy command surface: varlock proxy env --session abc12 varlock proxy status varlock proxy audit --session abc12 - varlock proxy refresh --session abc12 + varlock proxy reload --session abc12 varlock proxy stop --session abc12 varlock proxy stop --all `.trim(), @@ -288,7 +292,7 @@ function getAction(ctx: any): string { if (!action) { throw new CliExitError( 'Missing proxy action.', - { suggestion: 'Use one of: run, start, rules, env, status, audit, refresh, stop' }, + { suggestion: 'Use one of: run, start, rules, env, status, audit, reload, stop' }, ); } return action; @@ -315,7 +319,7 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise): Promise => { + const apply = async (request: ProxyReloadRequest): Promise => { + const { requestId } = request; + // Refuse a reload requested from inside the proxied agent: applying it would let the + // agent self-approve its own schema edit (re-blessing the fingerprint the guard pins). + // A human must run `varlock proxy reload` from a trusted terminal. Surface the attempt + // in the owner's log so a watching user sees it. (Self-reported signal, so a + // marker-stripping agent evades it, same as the other in-tree guards; the out-of-band + // approver is the real gate on a shared uid.) + if (request.requestedFromProxyChild) { + opts.log( + `⚠️ Refused a schema reload requested from inside the proxied agent (session ${opts.session.id}). ` + + 'This stops the agent from self-approving its own schema edit. If you made this change, run ' + + '`varlock proxy reload` yourself from a trusted terminal to apply it.', + ); + return { + requestId, + status: 'denied', + completedAt: new Date().toISOString(), + error: 'reload requested from inside the proxied agent; apply it from a trusted terminal instead', + }; + } try { const latest = await getProxySessionByToken(opts.session.uuid).catch(() => undefined); - const paths = entryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; + const paths = request.entryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; // TODO(approval): when a native/phone approver exists, request approval for // this schema change here and return status 'denied' if refused. Today the // reload is applied directly. @@ -689,7 +750,7 @@ function startReloadServicer(opts: { lastRequestId = request.requestId; busy = true; try { - const result = await apply(request.requestId, request.entryPaths); + const result = await apply(request); await writeReloadResult(opts.session.uuid, result).catch(() => undefined); } finally { busy = false; @@ -749,10 +810,16 @@ async function runAction(ctx: any) { await removeProxySessionAttachment(session.uuid, process.pid).catch(() => undefined); }; } else { - // Hot-reload is opt-in (see startAction). For a one-shot `proxy run` it's - // rarely needed — the next invocation re-reads the schema — so it stays off - // unless `--allow-reload` is passed. - const allowReload = ctx.values['allow-reload'] === true; + // Reload posture (see resolveReloadMode). A one-shot `proxy run` re-reads the + // schema on its next invocation, so `auto` resolves to off here; the flag or + // `@proxyConfig={reload="manual"}` can still opt this self-owned run in. + const { mode: reloadMode } = resolveReloadMode({ + flag: ctx.values['allow-reload'], + schema: policy.serializedGraph.settings?.proxyReload, + isStart: false, + hasTty: isHumanAttended(), + }); + const allowReload = reloadMode === 'manual'; const created = await createRuntimeAndSession({ policy, entryPaths: ctx.values.path, @@ -766,7 +833,7 @@ async function runAction(ctx: any) { session = created.session; statsWriter = created.statsWriter; console.error(`Proxy session ${session.id} active. Monitor with \`varlock proxy status --session ${session.id} --watch\`.`); - // This run owns its proxy, so it services its own `proxy refresh` reloads when + // This run owns its proxy, so it services its own `proxy reload` reloads when // enabled. Notices go to stderr (the child owns stdout). const reloadServicer = allowReload ? startReloadServicer({ @@ -878,11 +945,17 @@ async function runAction(ctx: any) { } async function startAction(ctx: any) { - // Hot-reload is opt-in: the reload channel is unauthenticated on a shared uid, - // so a same-uid agent could `proxy refresh` to self-approve a schema edit and - // defeat the fingerprint guard. Off by default; restart to change policy. - const allowReload = ctx.values['allow-reload'] === true; const policy = await prepareProxyPolicy(ctx.values.path); + // Reload posture, resolved at launch (see resolveReloadMode). `manual` runs the + // reload servicer so a human can `proxy reload` from this terminal; a reload + // requested from inside the agent is refused (it would re-bless the fingerprint). + const { mode: reloadMode, resolvedFromAuto } = resolveReloadMode({ + flag: ctx.values['allow-reload'], + schema: policy.serializedGraph.settings?.proxyReload, + isStart: true, + hasTty: isHumanAttended(), + }); + const allowReload = reloadMode === 'manual'; const { runtime, session, statsWriter, auditLog, } = await createRuntimeAndSession({ @@ -902,14 +975,15 @@ async function startAction(ctx: any) { console.log(`Started proxy session ${session.id} (${session.uuid})`); console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); + const autoNote = resolvedFromAuto ? ' (auto)' : ''; if (allowReload) { - console.log(`Use \`varlock proxy refresh --session ${session.id}\` to reload after editing your schema.`); + console.log(`Reload: manual${autoNote}. Run \`varlock proxy reload --session ${session.id}\` from this terminal after editing your schema; reloads requested from inside the agent are refused.`); } else { - console.log('Schema edits require a restart (hot-reload disabled; enable with `--allow-reload`).'); + console.log(`Reload: off${autoNote}. Schema edits need a restart. Enable live reload with \`--allow-reload\` or \`@proxyConfig={reload="manual"}\`.`); } console.log('Live request log (→ request · ← response):'); - // Service `proxy refresh` requests: hot-reload the live policy without dropping + // Service `proxy reload` requests: hot-reload the live policy without dropping // the proxy. The daemon owns this terminal, so reload notices print here. Only // runs when reload is explicitly enabled. const reloadServicer = allowReload @@ -1021,7 +1095,7 @@ async function statusAction(ctx: any) { process.off('SIGTERM', stopWatching); } -async function refreshAction(ctx: any) { +async function reloadAction(ctx: any) { const session = await resolveProxySessionForCommand({ explicitSession: ctx.values.session, env: process.env, @@ -1038,9 +1112,9 @@ async function refreshAction(ctx: any) { throw new CliExitError( `Proxy session ${session.id} has hot-reload disabled.`, { - suggestion: 'Restart the proxy to pick up schema edits, or start it with ' - + '`varlock proxy start --allow-reload` to enable live reloads. Note: the reload ' - + 'channel is unauthenticated on a shared uid, so only enable it behind a sandbox.', + suggestion: 'Restart the proxy to pick up schema edits, or enable live reload with ' + + '`varlock proxy start --allow-reload` (or `@proxyConfig={reload="manual"}`). On a shared uid the ' + + 'reload channel is only a bar-raiser, so prefer running behind a sandbox.', }, ); } @@ -1050,8 +1124,8 @@ async function refreshAction(ctx: any) { // Validate the new schema here (in this context) so an obviously broken edit // fails loudly at the call site, not only in the owner's logs. - const refreshPaths = ctx.values.path ?? session.entryPaths; - await prepareProxyPolicy(refreshPaths).catch((error) => { + const reloadPaths = ctx.values.path ?? session.entryPaths; + await prepareProxyPolicy(reloadPaths).catch((error) => { throw new CliExitError(`Schema does not resolve: ${(error as Error).message}`); }); @@ -1059,10 +1133,16 @@ async function refreshAction(ctx: any) { // then block until it reports a result. (When the native/phone approver lands, // the wait also spans the approval step — same blocking contract.) const requestId = newReloadRequestId(); + // Self-report whether this reload was invoked from inside the proxied agent. The owner + // refuses those (a human must apply schema edits from a trusted terminal) and logs the + // attempt. We send the request through anyway, rather than blocking locally, so the owner + // surfaces it to a watching user and hands back the explanation handled below. + const requestedFromProxyChild = await isInProxyContext(process.env); await writeReloadRequest(session.uuid, { requestId, requestedAt: new Date().toISOString(), ...(ctx.values.path?.length ? { entryPaths: ctx.values.path } : {}), + ...(requestedFromProxyChild ? { requestedFromProxyChild: true } : {}), }); let interrupted = false; @@ -1072,7 +1152,7 @@ async function refreshAction(ctx: any) { process.on('SIGINT', onInterrupt); process.on('SIGTERM', onInterrupt); - const deadline = Date.now() + REFRESH_TIMEOUT_MS; + const deadline = Date.now() + RELOAD_TIMEOUT_MS; let result: ProxyReloadResult | undefined; try { console.log(`Requested reload of proxy session ${session.id}; waiting…`); @@ -1088,7 +1168,7 @@ async function refreshAction(ctx: any) { throw new CliExitError(`Proxy session ${session.id} stopped before the reload completed.`); } await new Promise((resolve) => { - setTimeout(resolve, REFRESH_POLL_INTERVAL_MS); + setTimeout(resolve, RELOAD_RESULT_POLL_INTERVAL_MS); }); } } finally { @@ -1103,6 +1183,16 @@ async function refreshAction(ctx: any) { if (!result) { throw new CliExitError(`Timed out waiting for proxy session ${session.id} to reload.`); } + if (result.status === 'denied') { + throw new CliExitError( + 'Reload refused: this reload was requested from inside the proxied agent.', + { + details: result.error ? [result.error] : undefined, + suggestion: 'Schema edits must be applied by a human from a trusted terminal: run ' + + '`varlock proxy reload` outside `varlock proxy run` so an agent cannot self-approve its own edit.', + }, + ); + } if (result.status === 'error') { throw new CliExitError(`Proxy reload failed: ${result.error ?? 'unknown error'}`); } @@ -1110,7 +1200,7 @@ async function refreshAction(ctx: any) { console.log(`✓ Schema change reloaded for proxy session ${session.id}.`); console.log(' • New requests through the proxy use the updated rules and secrets.'); console.log(' • Newly launched `varlock proxy run -- …` children pick up the change; an already-running'); - console.log(' child keeps the env it started with (restart it to refresh its variables).'); + console.log(' child keeps the env it started with (restart it to pick up new variables).'); } async function stopAction(ctx: any) { @@ -1304,14 +1394,14 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = return await statusAction(ctx); case 'audit': return await auditAction(ctx); - case 'refresh': - return await refreshAction(ctx); + case 'reload': + return await reloadAction(ctx); case 'stop': return await stopAction(ctx); default: throw new CliExitError( `Unknown proxy action "${action}".`, - { suggestion: 'Use one of: run, start, rules, env, status, audit, refresh, stop' }, + { suggestion: 'Use one of: run, start, rules, env, status, audit, reload, stop' }, ); } }; diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index b2c3b5720..592b65262 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; -import { computeProxyChildView, isCwdWithin } from '../proxy.command.js'; +import { computeProxyChildView, isCwdWithin, resolveReloadMode } from '../proxy.command.js'; async function loadGraph(envFile: string) { const graph = new EnvGraph(); @@ -17,6 +17,47 @@ async function childView(graph: EnvGraph) { return computeProxyChildView(graph, managedItems); } +describe('resolveReloadMode', () => { + test('the flag wins: --allow-reload -> manual, --no-allow-reload -> off', () => { + // flag overrides schema and context in both directions + expect(resolveReloadMode({ + flag: true, schema: 'off', isStart: false, hasTty: false, + })) + .toEqual({ mode: 'manual', resolvedFromAuto: false }); + expect(resolveReloadMode({ + flag: false, schema: 'manual', isStart: true, hasTty: true, + })) + .toEqual({ mode: 'off', resolvedFromAuto: false }); + }); + + test('an explicit schema value (off/manual) is used verbatim', () => { + expect(resolveReloadMode({ + flag: undefined, schema: 'manual', isStart: false, hasTty: false, + })) + .toEqual({ mode: 'manual', resolvedFromAuto: false }); + expect(resolveReloadMode({ + flag: undefined, schema: 'off', isStart: true, hasTty: true, + })) + .toEqual({ mode: 'off', resolvedFromAuto: false }); + }); + + test('auto (explicit or defaulted) resolves conservatively from launch context', () => { + const auto = (isStart: boolean, hasTty: boolean) => resolveReloadMode({ + flag: undefined, schema: 'auto', isStart, hasTty, + }); + // manual only for an interactive `proxy start`; off everywhere else + expect(auto(true, true)).toEqual({ mode: 'manual', resolvedFromAuto: true }); + expect(auto(true, false)).toEqual({ mode: 'off', resolvedFromAuto: true }); // headless daemon + expect(auto(false, true)).toEqual({ mode: 'off', resolvedFromAuto: true }); // one-shot `proxy run` + expect(auto(false, false)).toEqual({ mode: 'off', resolvedFromAuto: true }); + // no schema value defaults to auto + expect(resolveReloadMode({ + flag: undefined, schema: undefined, isStart: true, hasTty: true, + })) + .toEqual({ mode: 'manual', resolvedFromAuto: true }); + }); +}); + describe('computeProxyChildView', () => { test('gives an unmanaged sensitive key a placeholder by default (not omitted)', async () => { const graph = await loadGraph(outdent` diff --git a/packages/varlock/src/cli/helpers/proxy-context-guard.ts b/packages/varlock/src/cli/helpers/proxy-context-guard.ts index 61973c149..9a592d0fd 100644 --- a/packages/varlock/src/cli/helpers/proxy-context-guard.ts +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -23,7 +23,7 @@ export function isProxyChildProcess(env: NodeJS.ProcessEnv = process.env): boole * can't defeat by clearing the marker. When the marker is absent but ancestry * says we're proxied, that's a likely bypass probe — logged as a signal. */ -async function isInProxyContext(env: NodeJS.ProcessEnv): Promise { +export async function isInProxyContext(env: NodeJS.ProcessEnv = process.env): Promise { if (isProxyChildProcess(env)) return true; const session = await getActiveProxySession(env); @@ -51,7 +51,7 @@ export async function enforceProxyContextGuards(rawArgs: Array, env: Nod throw new CliExitError( `Command blocked in proxied context: \`varlock proxy ${action}\` is disabled to prevent nested proxy execution.`, { - suggestion: 'Use `varlock proxy env`, `varlock proxy status`, or `varlock proxy refresh` from within proxied sessions.', + suggestion: 'Use `varlock proxy env` or `varlock proxy status` from within proxied sessions. (Applying a schema edit with `varlock proxy reload` must be done from a trusted terminal, not the agent.)', }, ); } diff --git a/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts index 6f2fefe27..66914c378 100644 --- a/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts +++ b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts @@ -44,6 +44,6 @@ describe('enforceProxyContextGuards', () => { test('allows non-launch proxy commands in proxied context', async () => { await expect(enforceProxyContextGuards(['proxy', 'status'], proxiedEnv)).resolves.toBeUndefined(); await expect(enforceProxyContextGuards(['proxy', 'env'], proxiedEnv)).resolves.toBeUndefined(); - await expect(enforceProxyContextGuards(['proxy', 'refresh'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['proxy', 'reload'], proxiedEnv)).resolves.toBeUndefined(); }); }); diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 51b89bd35..fb8b95f2f 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -554,15 +554,23 @@ export const builtInRootDecorators: Array> = [ throw new SchemaError('@proxyConfig must be set to an options object, for example @proxyConfig={egress="strict"}'); } for (const key in decValue.objArgs) { - if (key !== 'egress') { - throw new SchemaError(`@proxyConfig: unknown option "${key}" (only "egress" is supported)`); + if (key !== 'egress' && key !== 'reload') { + throw new SchemaError(`@proxyConfig: unknown option "${key}" (supported: egress, reload)`); } } const egressResolver = decValue.objArgs.egress; - if (!egressResolver || !egressResolver.isStatic) return; - const egressValue = egressResolver.staticValue; - if (egressValue !== 'permissive' && egressValue !== 'strict') { - throw new SchemaError('@proxyConfig: egress must be "permissive" or "strict"'); + if (egressResolver?.isStatic) { + const egressValue = egressResolver.staticValue; + if (egressValue !== 'permissive' && egressValue !== 'strict') { + throw new SchemaError('@proxyConfig: egress must be "permissive" or "strict"'); + } + } + const reloadResolver = decValue.objArgs.reload; + if (reloadResolver?.isStatic) { + const reloadValue = reloadResolver.staticValue; + if (reloadValue !== 'off' && reloadValue !== 'manual' && reloadValue !== 'auto') { + throw new SchemaError('@proxyConfig: reload must be "off", "manual", or "auto"'); + } } }, }, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index d3cb8c247..3cdd2683e 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -65,6 +65,8 @@ export type SerializedEnvGraph = { encryptInjectedEnv?: boolean; disableProcessEnvInjection?: boolean; proxyEgress?: ProxyEgressMode; + /** `@proxyConfig={reload=...}` posture; the proxy resolves `auto` at launch. */ + proxyReload?: 'off' | 'manual' | 'auto'; }, config: Record { expect(res).toMatchObject({ requestId, status: 'done', managedItemCount: 3 }); }); + test('carries the requestedFromProxyChild flag and a denied result (agent self-approval refusal)', async () => { + const uuid = 'sess-denied'; + await writeReloadRequest(uuid, { + requestId: 'c', requestedAt: '2026-06-16T00:00:00.000Z', requestedFromProxyChild: true, + }); + expect((await readReloadRequest(uuid))?.requestedFromProxyChild).toBe(true); + + await writeReloadResult(uuid, { + requestId: 'c', + status: 'denied', + completedAt: '2026-06-16T00:00:01.000Z', + error: 'reload requested from inside the proxied agent; apply it from a trusted terminal instead', + }); + expect((await readReloadResult(uuid))?.status).toBe('denied'); + }); + test('returns undefined when no request/result exists', async () => { expect(await readReloadRequest('missing')).toBeUndefined(); expect(await readReloadResult('missing')).toBeUndefined(); diff --git a/packages/varlock/src/proxy/reload-channel.ts b/packages/varlock/src/proxy/reload-channel.ts index 09d542660..dc2d4cc6b 100644 --- a/packages/varlock/src/proxy/reload-channel.ts +++ b/packages/varlock/src/proxy/reload-channel.ts @@ -8,10 +8,10 @@ import { dirname, join } from 'node:path'; import { getProxySessionDir } from './session-registry'; /** - * File-based request/result channel between `varlock proxy refresh` and the + * File-based request/result channel between `varlock proxy reload` and the * process that owns a live proxy runtime (a `proxy start` daemon or a - * self-owned `proxy run`). The refresh process writes a request; the owner polls - * for it, hot-reloads its policy, and writes back a result the refresh process + * self-owned `proxy run`). The reload process writes a request; the owner polls + * for it, hot-reloads its policy, and writes back a result the reload process * blocks on. Cross-platform (no signals) and holds no secret values. * * This is interim plumbing: when the native app / phone approver lands it talks @@ -26,10 +26,20 @@ export type ProxyReloadRequest = { requestedAt: string; /** Entry paths to resolve from; falls back to the session's stored paths. */ entryPaths?: Array; + /** + * Set when the reload was requested from inside the proxied agent (self-reported + * via the proxy-context markers). The owner refuses these so an agent can't + * self-approve its own schema edit; a human must run `proxy reload` from a + * trusted terminal. Not a hard boundary on a shared uid (a marker-stripping + * agent evades it, same as the other in-tree guards), but it catches the + * honest path and surfaces the attempt in the owner's log. The out-of-band + * approver is the real gate. + */ + requestedFromProxyChild?: boolean; }; -/** Both terminal. A future approver adds `denied` (+ a pending phase). */ -export type ProxyReloadStatus = 'done' | 'error'; +/** All terminal. `denied` = the owner refused it (today: requested from inside the agent). */ +export type ProxyReloadStatus = 'done' | 'error' | 'denied'; export type ProxyReloadResult = { requestId: string; diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index a94fc5f0b..103fb0e2c 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -35,7 +35,7 @@ export type ProxyRuntimeContext = { env: NodeJS.ProcessEnv; /** * Hot-swap the policy a running proxy enforces (rules, managed items, egress - * mode) without restarting — used by `proxy refresh` to apply schema edits to + * mode) without restarting — used by `proxy reload` to apply schema edits to * a live daemon. Takes effect on the next request; in-flight requests keep the * snapshot they already resolved. The proxy address and CA are unchanged. */ diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 5ff89acb6..19f59499f 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -26,7 +26,7 @@ export type ProxySessionRecord = { updatedAt: string; /** Set once the session's process has stopped. A session dir is a durable record; it's marked ended, not deleted. */ endedAt?: string; - /** A long-lived `proxy start` daemon that can hot-reload its policy on `proxy refresh` (via the file reload channel). One-shot `proxy run` sessions are not reloadable. */ + /** A long-lived `proxy start` daemon that can hot-reload its policy on `proxy reload` (via the file reload channel). One-shot `proxy run` sessions are not reloadable. */ reloadable?: boolean; /** * Pids of `proxy run` processes that attached to this (shared daemon) session. From 6532abee8ee9c6b60015ffee9c81c10f3c3e99b2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 13:29:59 -0700 Subject: [PATCH 55/64] feat(proxy): proxy start interactivity: keypress reload, schema drift warning, attach log - press r then y in an interactive proxy start to reload the schema in place; coordinates with approval prompts over the shared stdin and holds the live log during the confirm - watch the loaded env files (fs.watch, parse-only re-fingerprint) and warn once when the schema drifts from the running policy; resets when back in sync - announce proxy run clients attaching/detaching in the live log - output polish: colored session id, reload posture line with hints, separator before the request log --- .../src/content/docs/guides/proxy.mdx | 8 +- .../varlock/src/cli/commands/proxy.command.ts | 354 ++++++++++++++++-- .../cli/commands/test/proxy.command.test.ts | 46 ++- 3 files changed, 370 insertions(+), 38 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index 36a283815..f0f9222b2 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -311,7 +311,7 @@ varlock proxy status --watch # live updates ## Editing the schema while a session is running -For safety, a proxied command refuses to run if your `.env.schema` has changed since the proxy session started. This prevents an agent from editing the schema mid-session (downgrading `@sensitive`, or adding a new item that resolves a secret) to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: +For safety, a proxied command refuses to run if your env schema has changed since the proxy session started. This prevents an agent from editing the schema mid-session (downgrading `@sensitive`, or adding a new item that resolves a secret) to recover real values. After an intentional edit, **restart the proxy** from a trusted (non-proxied) shell so it re-resolves the new schema: ```bash varlock proxy stop --all @@ -320,8 +320,10 @@ varlock proxy start # or: varlock proxy run -- A one-shot `varlock proxy run` already re-reads the schema on its next invocation, so just re-run it. +A running `varlock proxy start` also **warns in its live log** when it detects your env schema changed on disk (any of the loaded `.env*` files, not just `.env.schema`). It keeps serving the previous policy (it does not block your agent's in-flight requests just because a file changed), so reload or restart to apply the change, and if you did not make the edit, a proxied process may have. + :::note[Hot-reload] -A running proxy can hot-reload your schema without a restart: run `varlock proxy reload` from a **trusted terminal** and it re-resolves the schema and swaps the live policy. A reload requested from **inside** the proxied agent is refused (so the agent can't self-approve its own schema edit) and the attempt is surfaced in the `proxy start` log. +A running proxy can hot-reload your schema without a restart: run `varlock proxy reload` from a **trusted terminal** and it re-resolves the schema and swaps the live policy. In an interactive `varlock proxy start`, you can also just press **`r`** in its terminal (then **`y`** to confirm) to reload in place. A reload requested from **inside** the proxied agent is refused (so the agent can't self-approve its own schema edit) and the attempt is surfaced in the `proxy start` log. Whether reload is available is set by [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) (`off` / `manual` / `auto`, default `auto`), and can be overridden per run with `--allow-reload` / `--no-allow-reload`. `auto` is conservative: it enables `manual` reload only for an interactive `varlock proxy start` (a human is there to apply it and watch the log) and stays `off` for headless runs or a one-shot `varlock proxy run`. On a shared uid the agent-refusal is a bar-raiser, not a hard boundary (a marker-stripping agent evades it), so `auto` only widens toward agent-triggered reload once containment (a sandbox) or a real out-of-band approver makes it safe. ::: @@ -334,7 +336,7 @@ The proxy is a local, same-machine tool. Know what it does and doesn't cover bef - **Response scrubbing is best-effort.** Responses are scrubbed back to placeholders only for **small, uncompressed text** bodies. **Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed**: the proxy can't scan into them. This only matters if an upstream *reflects your secret back in its response* (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected. - **Injection requires a verified public-TLS host.** Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext `http://` or into a self-signed/local upstream, by design. - **Default egress is not containment.** `egress=permissive` (the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Use `egress=strict` (and ideally a sandbox) if you want to constrain egress. -- **Hot-reload is human-applied (and off by default).** `proxy reload` only works when the daemon was started with `--allow-reload`, and a reload requested from inside the proxied agent is refused and logged so the agent can't self-approve a schema edit. But that refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, enable it only behind a sandbox. +- **Hot-reload is human-applied.** `proxy reload` (or pressing `r` in an interactive `proxy start`) re-resolves the schema and swaps the live policy; a reload requested from inside the proxied agent is refused and logged so the agent can't self-approve a schema edit. Availability follows [`@proxyConfig={reload=...}`](/reference/root-decorators/#proxyconfig) (default `auto`: on for an interactive daemon, off for headless or one-shot runs). But the agent-refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, prefer a sandbox. - **Only proxy-aware clients are covered.** The child is pointed at the proxy via standard `HTTP(S)_PROXY` / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won't get a working secret. ## Reference diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 085ffe464..30c73df7a 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { watch as fsWatch, existsSync, statSync } from 'node:fs'; import ansis from 'ansis'; import { define } from 'gunshi'; @@ -395,21 +396,35 @@ function flushDeferredProxyLogs(): void { for (const line of deferredProxyLogLines.splice(0)) console.error(line); } -/** Wrap an approval provider so the live log defers (rather than corrupts) the TTY while it prompts. */ +/** The daemon's raw-mode keypress controls (reload/quit); paused while an approval prompt owns stdin. */ +let daemonKeyControls: { pauseForPrompt(): void; resumeAfterPrompt(): void; stop(): void } | undefined; + +/** + * Wrap an approval provider so the live log defers (rather than corrupts) the TTY while it + * prompts, and the reload keypress reader yields stdin to the prompt (both readline and the + * raw-mode key listener would otherwise fight over stdin). + */ function guardApprovalPromptForLogging(inner: ApprovalProvider): ApprovalProvider { return { async requestApproval(req) { activeApprovalPrompts += 1; + daemonKeyControls?.pauseForPrompt(); try { return await inner.requestApproval(req); } finally { activeApprovalPrompts -= 1; + daemonKeyControls?.resumeAfterPrompt(); flushDeferredProxyLogs(); } }, }; } +/** The session short-id, colored so it stands out in the log preamble. */ +function fmtSessionId(id: string): string { + return ansis.magenta.bold(id); +} + /** Color an HTTP status by class: 2xx/3xx green, 4xx yellow, 5xx red. */ function colorProxyStatus(code: number): string { const text = String(code); @@ -666,6 +681,131 @@ async function resolveAttachSession(ctx: any, schemaFingerprint: string): Promis return candidate; } +/** + * Apply a trusted reload: re-resolve the schema and swap the live policy on the running + * runtime. Used by both the file-channel servicer (for a remote `proxy reload`) and the + * in-terminal keypress. No refusal check here: callers must have already established the + * request is trusted (a human keypress at the daemon TTY, or a non-agent `proxy reload`). + */ +async function applyTrustedReload(opts: { + session: ProxySessionRecord; + runtime: Awaited>; + requestedEntryPaths?: Array; + defaultEntryPaths?: Array; + log: (message: string) => void; +}): Promise<{ ok: true; managedItemCount: number } | { ok: false; error: string }> { + try { + const latest = await getProxySessionByToken(opts.session.uuid).catch(() => undefined); + const paths = opts.requestedEntryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; + const next = await prepareProxyPolicy(paths); + opts.runtime.reconfigure({ + managedItems: next.proxyManagedItems, + rules: next.proxyRules, + egressMode: next.egressMode, + }); + await updateProxySessionRecord(opts.session.uuid, { + schemaFingerprint: next.schemaFingerprint, + egressMode: next.egressMode, + placeholderOverrides: next.placeholderByKey, + omittedKeys: next.omittedKeys, + }); + opts.log(`Reloaded proxy session ${opts.session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); + return { ok: true, managedItemCount: next.proxyManagedItems.length }; + } catch (error) { + opts.log(`Proxy reload failed: ${(error as Error).message}`); + return { ok: false, error: (error as Error).message }; + } +} + +export type ReloadKeyState = 'idle' | 'confirming'; + +/** + * Two-step reload trigger for the `proxy start` terminal: `r` arms it, then `y` confirms + * (any other key cancels), so a stray keystroke can't swap the live policy. Pure state + * machine so it is testable without a real TTY; the caller wires the side effects. + */ +export function createReloadKeypressHandler(opts: { + onArm: () => void; + onCancel: () => void; + onConfirm: () => void; +}): { handleKey: (key: string) => void; state: () => ReloadKeyState } { + let state: ReloadKeyState = 'idle'; + return { + state: () => state, + handleKey(key) { + if (state === 'idle') { + if (key === 'r' || key === 'R') { + state = 'confirming'; + opts.onArm(); + } + return; + } + state = 'idle'; + if (key === 'y' || key === 'Y') opts.onConfirm(); + else opts.onCancel(); + }, + }; +} + +/** + * Raw-mode stdin controls for the interactive daemon: `r` then `y` reloads, Ctrl-C quits. + * Returns undefined when there is no TTY (headless daemons reload via `proxy reload`). + * `pauseForPrompt`/`resumeAfterPrompt` hand stdin to the approval readline while it prompts. + */ +function startDaemonKeyControls(opts: { + onReload: () => void; + onQuit: () => void; +}): { pauseForPrompt(): void; resumeAfterPrompt(): void; stop(): void } | undefined { + if (!process.stdin.isTTY) return undefined; + const promptText = `\n${ansis.yellow('?')} Reload the schema and swap the live policy? ${ansis.dim('press y to confirm, any other key cancels')}`; + const handler = createReloadKeypressHandler({ + // Hold the live request log while the confirm prompt is up, and print the prompt + // directly (bypassing the hold), so it doesn't scroll away. This is the same trick + // the approval prompt uses; the held lines flush once the user answers. + onArm: () => { + activeApprovalPrompts += 1; + console.error(promptText); + }, + onCancel: () => { + console.error(ansis.dim(' reload cancelled\n')); + activeApprovalPrompts -= 1; + flushDeferredProxyLogs(); + }, + onConfirm: () => { + activeApprovalPrompts -= 1; + flushDeferredProxyLogs(); + opts.onReload(); + }, + }); + const onData = (buf: Buffer) => { + for (const ch of buf.toString('utf8')) { + if (ch === '\u0003') { // Ctrl-C (raw mode swallows the default SIGINT) + opts.onQuit(); + return; + } + handler.handleKey(ch); + } + }; + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.on('data', onData); + return { + pauseForPrompt() { + process.stdin.off('data', onData); + process.stdin.setRawMode?.(false); + }, + resumeAfterPrompt() { + process.stdin.setRawMode?.(true); + process.stdin.on('data', onData); + }, + stop() { + process.stdin.off('data', onData); + process.stdin.setRawMode?.(false); + process.stdin.pause(); + }, + }; +} + /** How often a proxy owner polls for a reload request. */ const RELOAD_POLL_INTERVAL_MS = 500; /** How often `proxy reload` polls for its result, and how long it waits. */ @@ -713,34 +853,22 @@ function startReloadServicer(opts: { error: 'reload requested from inside the proxied agent; apply it from a trusted terminal instead', }; } - try { - const latest = await getProxySessionByToken(opts.session.uuid).catch(() => undefined); - const paths = request.entryPaths ?? latest?.entryPaths ?? opts.defaultEntryPaths; - // TODO(approval): when a native/phone approver exists, request approval for - // this schema change here and return status 'denied' if refused. Today the - // reload is applied directly. - const next = await prepareProxyPolicy(paths); - opts.runtime.reconfigure({ - managedItems: next.proxyManagedItems, - rules: next.proxyRules, - egressMode: next.egressMode, - }); - await updateProxySessionRecord(opts.session.uuid, { - schemaFingerprint: next.schemaFingerprint, - egressMode: next.egressMode, - placeholderOverrides: next.placeholderByKey, - omittedKeys: next.omittedKeys, - }); - opts.log(`Reloaded proxy session ${opts.session.id} from schema (${next.proxyManagedItems.length} managed item(s)).`); - return { - requestId, status: 'done', completedAt: new Date().toISOString(), managedItemCount: next.proxyManagedItems.length, - }; - } catch (error) { - opts.log(`Proxy reload failed: ${(error as Error).message}`); - return { - requestId, status: 'error', completedAt: new Date().toISOString(), error: (error as Error).message, + // TODO(approval): when a native/phone approver exists, gate a non-agent reload + // here and return status 'denied' if refused. Today it applies directly. + const result = await applyTrustedReload({ + session: opts.session, + runtime: opts.runtime, + requestedEntryPaths: request.entryPaths, + defaultEntryPaths: opts.defaultEntryPaths, + log: opts.log, + }); + return result.ok + ? { + requestId, status: 'done', completedAt: new Date().toISOString(), managedItemCount: result.managedItemCount, + } + : { + requestId, status: 'error', completedAt: new Date().toISOString(), error: result.error, }; - } }; const tick = async () => { @@ -771,6 +899,126 @@ function startReloadServicer(opts: { return { stop: () => clearInterval(timer) }; } +/** + * Watch the session record for `proxy run` clients attaching/detaching and print a + * line to the daemon's live log when one connects or leaves, plus the running count, + * so a watching human has visibility into who's using the proxy. Clients are + * distinguished by pid; attributing an individual request line to a specific client + * needs per-client proxy identity (a bigger change) and is not done here. + */ +function startAttachmentWatcher(opts: { sessionUuid: string; log: (msg: string) => void }): { stop: () => void } { + let known = new Set(); + let seeded = false; + const tick = async () => { + const rec = await getProxySessionByToken(opts.sessionUuid).catch(() => undefined); + if (!rec) return; + const current = new Set((rec.attachedPids ?? []).filter((p) => isProcessRunning(p))); + // seed silently on the first tick so we only announce changes, not the initial state + if (!seeded) { + known = current; + seeded = true; + return; + } + let changed = false; + for (const pid of current) { + if (known.has(pid)) continue; + changed = true; + opts.log(`${ansis.green('⊕')} client attached ${ansis.dim(`(pid ${pid})`)}`); + } + for (const pid of known) { + if (current.has(pid)) continue; + changed = true; + opts.log(`${ansis.yellow('⊖')} client detached ${ansis.dim(`(pid ${pid})`)}`); + } + if (changed) opts.log(ansis.dim(` ${current.size} client${current.size === 1 ? '' : 's'} connected`)); + known = current; + }; + const timer = setInterval(() => { + tick().catch(() => undefined); + }, 1000); + timer.unref?.(); + return { stop: () => clearInterval(timer) }; +} + +/** + * Watch the schema files and warn (do not block) when the on-disk schema drifts from the + * running proxy's policy. We deliberately don't block live requests on drift: the agent's + * traffic (e.g. LLM calls) flows through this proxy, and blocking it would strand the agent. + * Instead we surface it so a human can `proxy reload` to apply, or notice that a proxied + * process edited the schema. Uses `fs.watch` (event-driven, no polling) and re-derives the + * fingerprint only on a change (parse-only, so no secret resolution). + */ +function startSchemaDriftWatcher(opts: { + sessionUuid: string; + entryPaths?: Array; + reloadHint: string; + log: (msg: string) => void; +}): { stop: () => void } { + // Watch the directories holding the entry files (a dir watch catches atomic saves, + // which rename a temp file into place, and new/removed `.env*` files); default to cwd. + const dirs = new Set(); + const entries = opts.entryPaths?.filter(Boolean); + if (entries?.length) { + for (const p of entries) { + dirs.add(existsSync(p) && statSync(p).isDirectory() ? p : path.dirname(p)); + } + } else { + dirs.add(process.cwd()); + } + + let warnedFingerprint: string | undefined; + let debounce: ReturnType | undefined; + + const check = async () => { + let current: string; + try { + const graph = await loadVarlockEnvGraph({ entryFilePaths: opts.entryPaths, skipProxyFingerprintGuard: true }); + current = buildProxySchemaFingerprint(graph); + } catch { + return; // schema mid-edit / unparseable, retry on the next change event + } + const rec = await getProxySessionByToken(opts.sessionUuid).catch(() => undefined); + const running = rec?.schemaFingerprint; + if (!running || current === running) { + warnedFingerprint = undefined; // in sync (reload applied, or the edit was reverted) + return; + } + if (warnedFingerprint === current) return; // already warned for this exact drift + warnedFingerprint = current; + opts.log( + `${ansis.yellow('⚠')} Your env schema changed since this proxy started. New requests still use the ` + + `previous policy (rules, injected secrets, egress). ${opts.reloadHint} ` + + `${ansis.dim('If you did not edit it, a proxied process may have.')}`, + ); + }; + + const onFsEvent = (_event: string, filename: string | Buffer | null) => { + // React only to `.env*` files (schema + overrides); some platforms omit the filename. + const name = typeof filename === 'string' ? filename : filename?.toString(); + if (name && !name.startsWith('.env')) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + check().catch(() => undefined); + }, 250); + }; + + const watchers: Array> = []; + for (const dir of dirs) { + try { + watchers.push(fsWatch(dir, { persistent: false }, onFsEvent)); + } catch { + // directory not watchable on this platform/fs; skip (drift warning is best-effort) + } + } + + return { + stop() { + if (debounce) clearTimeout(debounce); + for (const w of watchers) w.close(); + }, + }; +} + async function runAction(ctx: any) { const commandToRunAsArgs = getRunCommandArgs(); const rawCommand = commandToRunAsArgs[0]!; @@ -947,7 +1195,7 @@ async function runAction(ctx: any) { async function startAction(ctx: any) { const policy = await prepareProxyPolicy(ctx.values.path); // Reload posture, resolved at launch (see resolveReloadMode). `manual` runs the - // reload servicer so a human can `proxy reload` from this terminal; a reload + // reload servicer so a human can `proxy reload` from another shell in the folder; a reload // requested from inside the agent is refused (it would re-bless the fingerprint). const { mode: reloadMode, resolvedFromAuto } = resolveReloadMode({ flag: ctx.values['allow-reload'], @@ -972,16 +1220,18 @@ async function startAction(ctx: any) { logRequests: true, }); - console.log(`Started proxy session ${session.id} (${session.uuid})`); - console.log(`Use \`varlock proxy env --session ${session.id}\` to print env exports.`); - console.log(`Use \`varlock proxy status --session ${session.id} --watch\` to monitor activity.`); + console.log(`Started proxy session ${fmtSessionId(session.id)} ${ansis.dim(`(${session.uuid})`)}`); + console.log(ansis.dim(`Run \`varlock proxy env\` / \`reload\` / \`stop\` from this folder and they target this session automatically (or pass \`--session ${session.id}\` from elsewhere).`)); const autoNote = resolvedFromAuto ? ' (auto)' : ''; + const canKeypressReload = allowReload && !!process.stdin.isTTY; if (allowReload) { - console.log(`Reload: manual${autoNote}. Run \`varlock proxy reload --session ${session.id}\` from this terminal after editing your schema; reloads requested from inside the agent are refused.`); + const keyHint = canKeypressReload ? ' Or press `r` here (then `y` to confirm).' : ''; + console.log(`Reload: manual${autoNote}. After editing your schema, run \`varlock proxy reload\` from another shell in this folder (this one is running the proxy).${keyHint} Reloads from inside the agent are refused.`); } else { console.log(`Reload: off${autoNote}. Schema edits need a restart. Enable live reload with \`--allow-reload\` or \`@proxyConfig={reload="manual"}\`.`); } - console.log('Live request log (→ request · ← response):'); + console.log(ansis.dim('─'.repeat(52))); + console.log(ansis.dim('Live request log (→ request · ← response · ⊕ client)')); // Service `proxy reload` requests: hot-reload the live policy without dropping // the proxy. The daemon owns this terminal, so reload notices print here. Only @@ -995,11 +1245,32 @@ async function startAction(ctx: any) { }) : undefined; + // Announce `proxy run` clients attaching/detaching in the live log. + const attachmentWatcher = startAttachmentWatcher({ sessionUuid: session.uuid, log: emitProxyLog }); + + // Warn (never block) when the on-disk schema drifts from the running policy. + let reloadHint = 'Restart the proxy to apply it.'; + if (allowReload) { + reloadHint = canKeypressReload + ? 'Run `varlock proxy reload` (or press r here) to apply it.' + : 'Run `varlock proxy reload` to apply it.'; + } + const schemaDriftWatcher = startSchemaDriftWatcher({ + sessionUuid: session.uuid, + entryPaths: ctx.values.path, + reloadHint, + log: emitProxyLog, + }); + let cleanedUp = false; const cleanup = async () => { if (cleanedUp) return; cleanedUp = true; reloadServicer?.stop(); + attachmentWatcher.stop(); + schemaDriftWatcher.stop(); + daemonKeyControls?.stop(); + daemonKeyControls = undefined; await statsWriter.flushNow(); statsWriter.stop(); await runtime.stop().catch(() => undefined); @@ -1015,6 +1286,21 @@ async function startAction(ctx: any) { process.on('SIGINT', close); process.on('SIGTERM', close); + + // Interactive daemon: `r` then `y` reloads the schema in place; Ctrl-C quits. + if (canKeypressReload) { + daemonKeyControls = startDaemonKeyControls({ + onReload: () => { + applyTrustedReload({ + session, + runtime, + defaultEntryPaths: ctx.values.path, + log: emitProxyLog, + }).catch(() => undefined); + }, + onQuit: close, + }); + } }); } diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index 592b65262..f33dad7f4 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; -import { computeProxyChildView, isCwdWithin, resolveReloadMode } from '../proxy.command.js'; +import { + computeProxyChildView, isCwdWithin, resolveReloadMode, createReloadKeypressHandler, +} from '../proxy.command.js'; async function loadGraph(envFile: string) { const graph = new EnvGraph(); @@ -58,6 +60,48 @@ describe('resolveReloadMode', () => { }); }); +describe('createReloadKeypressHandler (two-step r -> y reload)', () => { + function make() { + const calls: Array = []; + const h = createReloadKeypressHandler({ + onArm: () => calls.push('arm'), + onCancel: () => calls.push('cancel'), + onConfirm: () => calls.push('confirm'), + }); + return { h, calls }; + } + + test('r arms, then y confirms', () => { + const { h, calls } = make(); + h.handleKey('r'); + expect(h.state()).toBe('confirming'); + expect(calls).toEqual(['arm']); + h.handleKey('y'); + expect(h.state()).toBe('idle'); + expect(calls).toEqual(['arm', 'confirm']); + }); + + test('r then any non-y cancels (a stray key cannot reload)', () => { + const { h, calls } = make(); + h.handleKey('r'); + h.handleKey('n'); + expect(h.state()).toBe('idle'); + expect(calls).toEqual(['arm', 'cancel']); + // and it takes two keys again to reload (no single-key reload) + h.handleKey('y'); // ignored in idle + expect(calls).toEqual(['arm', 'cancel']); + }); + + test('a lone key in idle does nothing; R and Y are accepted', () => { + const { h, calls } = make(); + h.handleKey('x'); + expect(calls).toEqual([]); + h.handleKey('R'); + h.handleKey('Y'); + expect(calls).toEqual(['arm', 'confirm']); + }); +}); + describe('computeProxyChildView', () => { test('gives an unmanaged sensitive key a placeholder by default (not omitted)', async () => { const graph = await loadGraph(outdent` From c8e8a5f984e9c464fd3726386923f29bb1731b05 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 13:30:11 -0700 Subject: [PATCH 56/64] chore(proxy): finish refresh to reload wording in guard messages and changeset --- .bumpy/proxy-schema-fingerprint-full.md | 2 +- .../varlock/src/cli/helpers/proxy-schema-fingerprint.ts | 6 +++--- packages/varlock/src/lib/load-graph.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpy/proxy-schema-fingerprint-full.md b/.bumpy/proxy-schema-fingerprint-full.md index 861e92148..28326a4b2 100644 --- a/.bumpy/proxy-schema-fingerprint-full.md +++ b/.bumpy/proxy-schema-fingerprint-full.md @@ -4,4 +4,4 @@ varlock: patch Proxy: the schema fingerprint now covers the full schema definition. -The fingerprint that guards an active proxy session against schema drift (and that `varlock proxy refresh` re-approves) now hashes each config item's value definitions (pre-resolution — no secrets, no I/O) plus every decorator, and all root decorators — instead of only key/sensitivity/required/type. Cosmetic decorators (`@example`, `@docs`, `@docsUrl`, `@icon`, `@deprecated`) are marked `inert` and excluded, and decorator order, named-arg order, comments, and whitespace don't affect it. This closes gaps where a behavioral change left the fingerprint unchanged — e.g. flipping a secret from `@proxy(domain=…)` to `@proxy=passthrough`, changing a `@proxy` domain, or the egress mode. +The fingerprint that guards an active proxy session against schema drift (and that `varlock proxy reload` re-applies) now hashes each config item's value definitions (pre-resolution: no secrets, no I/O) plus every decorator, and all root decorators, instead of only key/sensitivity/required/type. Cosmetic decorators (`@example`, `@docs`, `@docsUrl`, `@icon`, `@deprecated`) are marked `inert` and excluded, and decorator order, named-arg order, comments, and whitespace don't affect it. This closes gaps where a behavioral change left the fingerprint unchanged, e.g. flipping a secret from `@proxy(domain=…)` to `@proxy=passthrough`, changing a `@proxy` domain, or the egress mode. diff --git a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts index 6d372e6c0..1e88bf3a6 100644 --- a/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -96,7 +96,7 @@ export function buildProxySchemaFingerprint(envGraph: EnvGraph): string { * schema fingerprint and refuse if it no longer matches the one captured when * the session started. Closes the "edit .env.schema, flip @sensitive off, * re-load to recover raw values" downgrade — a deliberate schema change must be - * re-approved out-of-band via `varlock proxy refresh`. + * re-applied out-of-band via `varlock proxy reload`. * * No-ops outside a proxied context, and when there's no stored fingerprint to * compare against (fails open rather than blocking unrelated commands). @@ -125,8 +125,8 @@ export async function enforceProxySchemaFingerprint( if (actual === expected) return; throw new CliExitError('Schema changed inside an active proxy session.', { - details: 'The resolved .env.schema no longer matches the fingerprint captured when the proxy session started. This guards against editing the schema mid-session (e.g. downgrading @sensitive, or adding a new item that resolves a secret) to recover real values.', - suggestion: 'Restart the proxy from a trusted (non-proxied) context to pick up the change. (If it was started with `varlock proxy start --allow-reload`, run `varlock proxy refresh` from a trusted context instead.)', + details: 'Your resolved env schema no longer matches the fingerprint captured when the proxy session started. This guards against editing the schema mid-session (e.g. downgrading @sensitive, or adding a new item that resolves a secret) to recover real values.', + suggestion: 'Restart the proxy from a trusted (non-proxied) context to pick up the change. (If the proxy allows reload, e.g. started with `--allow-reload` or `@proxyConfig={reload="manual"}`, run `varlock proxy reload` from a trusted context instead.)', forceExit: true, }); } diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index d85d46f3f..f59df4fa5 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -102,7 +102,7 @@ export async function loadVarlockEnvGraph(opts?: { /** * Skip the proxy schema-fingerprint guard for this load. Used by the `proxy` * command itself (it manages the session fingerprint directly), so that - * `proxy refresh` can re-approve a schema change without being blocked by the + * `proxy reload` can apply a schema change without being blocked by the * very guard it exists to clear. */ skipProxyFingerprintGuard?: boolean, From 3e61c4ca56ddfda60f66dba5acb09fbe18442e23 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 13:30:12 -0700 Subject: [PATCH 57/64] fix(proxy): attribute block responses to varlock and name the blocked host All five block responses (strict egress on both the HTTP and CONNECT paths, block rules, cleartext refusal, approval denied) now lead with 'Blocked by the varlock credential proxy', name the host, and explain the reason, so a proxied agent can tell varlock blocked the request and adjust instead of blaming the upstream. --- packages/varlock/src/proxy/runtime-proxy.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 103fb0e2c..4a42205d1 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -596,7 +596,7 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', }); - respondBlocked(res, 403, 'Proxy egress blocked by strict mode', false); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: ${t.host} is not allowed by your egress policy (strict mode only permits hosts with a matching @proxy rule). Add an @proxy rule for this host, or use permissive egress, to allow it.`, false); return; } @@ -610,7 +610,7 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', }); - respondBlocked(res, 403, 'Blocked by proxy policy', t.tunnelTeardown); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: a @proxy block rule denies this request to ${t.host}.`, t.tunnelTeardown); return; } @@ -623,7 +623,7 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'blocked-cleartext', }); - respondBlocked(res, 403, 'Refusing to inject secrets into a cleartext (non-TLS) connection', false); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: refusing to inject a secret into a cleartext (non-TLS) connection to ${t.host}.`, false); return; } @@ -650,7 +650,7 @@ export async function startLocalProxyRuntime({ onActivity?.({ ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', }); - respondBlocked(res, 403, 'Request denied: approval not granted', t.tunnelTeardown); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: this request to ${t.host} required approval and it was not granted.`, t.tunnelTeardown); return; } } @@ -858,7 +858,7 @@ export async function startLocalProxyRuntime({ blocked: true, decision: 'blocked-egress', }); - const blockedBody = 'Proxy egress blocked by strict mode'; + const blockedBody = `Blocked by the varlock credential proxy: ${hostInfo.host} is not allowed by your egress policy (strict mode only permits hosts with a matching @proxy rule). Add an @proxy rule for this host, or use permissive egress, to allow it.`; clientSocket.write( `HTTP/1.1 403 Forbidden\r\nContent-Length: ${Buffer.byteLength(blockedBody)}\r\nConnection: close\r\n\r\n${blockedBody}`, ); From d771946791aa619b04a4a7aad95a58382b870fc1 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 14:01:36 -0700 Subject: [PATCH 58/64] feat(proxy): attaching proxy run adopts the session's env via a varlock.internal endpoint An attaching proxy run no longer resolves the schema itself (which triggered a second unlock prompt and let the attaching shell's env reshape resolution). Instead the daemon serves its child-view env (placeholders, non-secret values, omitted keys, serialized graph) on a token-gated varlock.internal endpoint riding the existing proxy port, and the attach adopts it verbatim: the session's own overrides and env selection apply, and nothing resolved is written to disk. - one-shot and attach share one spawn pipeline (single payload producer, shared serializer the one-shot path round-trips through, single child-env consumer), so the two paths cannot diverge - omitted keys are now deleted from the child env even when the launching shell exports them - reload re-serves the current payload, so post-reload attaches adopt the reloaded env - attach fetch owns its timeout with a real timer: compiled Bun never emits the http request timeout event, which silently exited 0 mid-await against a hung daemon --- .bumpy/proxy-attach-adopts-session-env.md | 7 + .../src/content/docs/guides/proxy.mdx | 2 + .../varlock/src/cli/commands/proxy.command.ts | 335 +++++++++++++----- .../cli/commands/test/proxy.command.test.ts | 81 ++++- .../varlock/src/proxy/runtime-proxy.test.ts | 104 ++++++ packages/varlock/src/proxy/runtime-proxy.ts | 69 ++++ .../src/proxy/session-env-payload.test.ts | 40 +++ .../varlock/src/proxy/session-env-payload.ts | 88 +++++ 8 files changed, 640 insertions(+), 86 deletions(-) create mode 100644 .bumpy/proxy-attach-adopts-session-env.md create mode 100644 packages/varlock/src/proxy/session-env-payload.test.ts create mode 100644 packages/varlock/src/proxy/session-env-payload.ts diff --git a/.bumpy/proxy-attach-adopts-session-env.md b/.bumpy/proxy-attach-adopts-session-env.md new file mode 100644 index 000000000..103e0239e --- /dev/null +++ b/.bumpy/proxy-attach-adopts-session-env.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Proxy: attaching `proxy run` now adopts the running session's env. + +When `varlock proxy run` attaches to a running `proxy start` daemon for the directory, it now fetches the child-view env (placeholders, non-secret values, omitted keys) directly from the daemon instead of re-resolving the schema itself. Attaching no longer triggers a second unlock prompt, and the session's own overrides and env selection apply to the child, so attaching from a shell with different env vars can no longer produce a mismatched env. diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index f0f9222b2..a9f9dc3f5 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -257,6 +257,8 @@ varlock proxy run -- claude `proxy start` is a long-lived session that owns its terminal, where the live per-request log appears. Attach to it from another terminal (or shell) so the agent's stdio doesn't compete with the daemon's output. +An attaching `proxy run` **adopts the running session's env**: it fetches the child view (placeholders, non-secret values, omitted keys) directly from the daemon instead of resolving anything itself. That means no second unlock prompt, and the session's own overrides and env selection apply (attaching means "run me in that session's env"). Your shell's ambient values for schema-managed keys are ignored in favor of the session's; everything else (`PATH`, etc.) passes through as usual. + ### Manual env: `proxy start` + `proxy env` ```bash diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 30c73df7a..3f33fb8ac 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { watch as fsWatch, existsSync, statSync } from 'node:fs'; +import { request as httpRequest } from 'node:http'; import ansis from 'ansis'; import { define } from 'gunshi'; @@ -54,6 +55,15 @@ import { type ProxyReloadResult, } from '../../proxy/reload-channel'; import { isInProxyContext } from '../helpers/proxy-context-guard'; +import { + buildSessionEnvPayload, + decodeSessionEnvPayload, + encodeSessionEnvPayload, + PROXY_TOKEN_HEADER, + SESSION_ENV_ENDPOINT_PATH, + VARLOCK_INTERNAL_HOST, + type SessionEnvPayload, +} from '../../proxy/session-env-payload'; import type { ProxyManagedItem, ProxyRule } from '../../proxy/types'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { isVarlockReservedKey } from '../../env-graph/lib/reserved-vars'; @@ -377,6 +387,199 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise; + parentPid: number; + injectVars: boolean; + injectBlob: boolean; + baseEnv: NodeJS.ProcessEnv; +}): NodeJS.ProcessEnv { + const fullInjectedEnv: NodeJS.ProcessEnv = { + ...opts.baseEnv, + ...opts.sessionExportEnv, + [PROXY_PARENT_PID_ENV_VAR]: String(opts.parentPid), + ...(opts.injectVars ? opts.payload.env : {}), + __VARLOCK_RUN: '1', + ...(opts.injectBlob ? { __VARLOCK_ENV: JSON.stringify(opts.payload.serializedGraph) } : {}), + }; + + // An omitted key must be absent from the child env entirely, even when the + // launching/attaching shell happens to export a value for it. + for (const key of opts.payload.omittedKeys) { + delete fullInjectedEnv[key]; + } + + if (opts.injectBlob && !opts.injectVars && opts.baseEnv._VARLOCK_ENV_KEY) { + fullInjectedEnv._VARLOCK_ENV_KEY = opts.baseEnv._VARLOCK_ENV_KEY; + } + + return fullInjectedEnv; +} + +/** + * Spawn the proxied child from a session-env payload: the ONLY consumer of the + * payload, shared by one-shot (in-memory payload) and attach (fetched payload) + * so the two paths cannot diverge. Assembles the child env, seeds stdout + * redaction, and wires the redacted streams. + */ +function spawnProxiedChild(opts: { + payload: SessionEnvPayload; + session: ProxySessionRecord; + rawCommand: string; + commandArgsOnly: Array; + injectVars: boolean; + injectBlob: boolean; + redactStdoutFlag?: boolean; + /** + * Wire real values for redaction seeding: self-owned runs only. An attached + * run never holds them (the payload has placeholders + passthrough values, + * which the serialized graph already covers). + */ + redactionManagedItems?: Array; +}) { + const fullInjectedEnv = buildProxiedChildEnv({ + payload: opts.payload, + sessionExportEnv: getProxySessionExportEnv(opts.session), + parentPid: process.pid, + injectVars: opts.injectVars, + injectBlob: opts.injectBlob, + baseEnv: process.env, + }); + + // Per-stream TTY auto-detect (interactive terminal -> raw inherit so tools like `claude` + // work; piped/redirected -> redact). Shared with `varlock run` so they can't diverge. + const { redactStdout, redactStderr } = resolveStdoutRedaction({ + redactStdoutFlag: opts.redactStdoutFlag, + redactLogs: opts.payload.serializedGraph.settings?.redactLogs ?? true, + }); + + // Seed the redaction map from the child-view graph (placeholders + passthrough + // values), plus the REAL values the proxy injects at the wire when this run owns + // the proxy, so if the child ever echoes an injected value back it is scrubbed. + if (redactStdout || redactStderr) { + const redactionGraph = { + ...opts.payload.serializedGraph, + config: { ...opts.payload.serializedGraph.config }, + }; + for (const managedItem of opts.redactionManagedItems ?? []) { + const schemaItem = opts.payload.serializedGraph.config[managedItem.key]; + if (!schemaItem?.isSensitive) continue; + if (!managedItem.realValue || managedItem.realValue === schemaItem.value) continue; + redactionGraph.config[`__PROXY_REAL__${managedItem.key}`] = { + value: managedItem.realValue, + isSensitive: true, + }; + } + resetRedactionMap(redactionGraph); + } + + // An inherited stream (both false) yields raw TTY pass-through, same as stdio: 'inherit'. + const commandProcess = exec(opts.rawCommand, opts.commandArgsOnly, { + stdin: 'inherit', + stdout: redactStdout ? 'pipe' : 'inherit', + stderr: redactStderr ? 'pipe' : 'inherit', + env: fullInjectedEnv, + }); + pipeRedactedStreams(commandProcess, { redactStdout, redactStderr }); + return commandProcess; +} + +const SESSION_ENV_FETCH_TIMEOUT_MS = 5000; + +/** + * Fetch the session-env payload from a running session's `varlock.internal` + * endpoint (via its own proxy port). Adopting this env is what attach MEANS: + * the owner resolved it in its own context (its shell overrides, its env + * selection); no schema load, resolution, or unlock prompt happens here. + * + * Any failure gets one clear message: never branch on the connect errno (Node + * and Bun report a dead endpoint differently). + */ +async function fetchSessionEnvPayload(session: ProxySessionRecord): Promise { + const unreachable = (details: string) => new CliExitError( + `Proxy session ${session.id} is not responding.`, + { + details, + suggestion: 'The session may have stopped or predate this varlock version. Restart it, or use `--new` to start a separate proxy.', + }, + ); + + let proxyUrl: URL; + try { + proxyUrl = new URL(session.env.HTTP_PROXY ?? ''); + } catch { + throw unreachable('Its record has no usable proxy address.'); + } + + const raw = await new Promise((resolve, reject) => { + // `timer` and `req` reference each other (timer destroys req; req's handlers + // clear timer), so one must be a let declared first. + // eslint-disable-next-line @nofix/prefer-const + let timer: ReturnType | undefined; + const req = httpRequest({ + host: proxyUrl.hostname, + port: Number(proxyUrl.port), + method: 'GET', + // absolute-form request target, the shape proxies receive + path: `http://${VARLOCK_INTERNAL_HOST}${SESSION_ENV_ENDPOINT_PATH}`, + headers: { + host: VARLOCK_INTERNAL_HOST, + [PROXY_TOKEN_HEADER]: session.uuid, + }, + }, (res) => { + if (res.statusCode !== 200) { + clearTimeout(timer); + res.resume(); + reject(new Error(`endpoint returned status ${res.statusCode}`)); + return; + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + clearTimeout(timer); + resolve(body); + }); + res.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); + req.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + // Own the timeout with a real timer: Bun's compiled runtime does not + // reliably emit the http `timeout` (or a subsequent `error`) event for the + // request `timeout` option — the socket just dies and the promise would + // never settle, so the process would exit 0 silently mid-await. + timer = setTimeout(() => { + reject(new Error('timed out')); + req.destroy(); + }, SESSION_ENV_FETCH_TIMEOUT_MS); + req.end(); + }).catch((error) => { + throw unreachable(`Fetching its env failed: ${(error as Error).message}.`); + }); + + try { + return decodeSessionEnvPayload(raw); + } catch (error) { + throw unreachable(`It served an unusable env payload: ${(error as Error).message}.`); + } +} + // The live request log and an interactive approval prompt share the same TTY // (stderr). While a prompt is awaiting input, defer log lines so a concurrent // request can't clobber the readline prompt; flush them once the prompt resolves. @@ -512,7 +715,11 @@ async function createRuntimeAndSession(opts: { onResponse: opts.logRequests ? (info) => emitProxyLog(formatProxyResponseLog(info)) : undefined, + internalEndpointToken: identity.uuid, }); + // Serve the child view to attaching `proxy run` processes (adopt semantics); + // applyTrustedReload re-sets this after each reload so fetches stay current. + runtime.setSessionEnvPayloadJson(encodeSessionEnvPayload(buildSessionEnvPayload(opts.policy))); const session = await createProxySessionRecord({ id: identity.id, uuid: identity.uuid, @@ -640,45 +847,37 @@ function isHumanAttended(): boolean { /** * Resolve the running proxy session to attach `proxy run` to: an explicit * `--session`, else the single session whose cwd contains this one. Returns - * undefined (→ start a fresh proxy) when nothing matches. Validates the schema - * fingerprint and fails loudly on drift, so an attached command can't route - * through a proxy started with a different `.env.schema`. + * undefined (→ start a fresh proxy) when nothing matches. + * + * No schema/fingerprint check here: attach ADOPTS the session's env (fetched + * from the owner), so there is no locally-resolved schema to compare. Disk + * drift stays handled where it belongs: the owner's drift watcher warns the + * human, and the in-child fingerprint guard blocks nested resolves until a + * reload re-blesses the change. */ -async function resolveAttachSession(ctx: any, schemaFingerprint: string): Promise { +async function resolveAttachSession(ctx: any): Promise { await cleanupStaleProxySessions(); - let candidate: ProxySessionRecord | undefined; if (ctx.values.session) { - candidate = await resolveProxySessionForCommand({ + return resolveProxySessionForCommand({ explicitSession: ctx.values.session, env: process.env, defaultToSingleActive: false, }).catch((error) => { throw new CliExitError((error as Error).message); }); - } else { - const cwd = process.cwd(); - const matches = (await listProxySessions()).filter((s) => isCwdWithin(cwd, s.cwd)); - if (matches.length === 0) return undefined; // → start a fresh proxy - if (matches.length > 1) { - throw new CliExitError( - `Multiple proxy sessions match this directory: ${matches.map((s) => s.id).join(', ')}.`, - { suggestion: 'Pass --session to choose one, or --new to start a separate proxy.' }, - ); - } - [candidate] = matches; } - if (candidate.schemaFingerprint && candidate.schemaFingerprint !== schemaFingerprint) { + const cwd = process.cwd(); + const matches = (await listProxySessions()).filter((s) => isCwdWithin(cwd, s.cwd)); + if (matches.length === 0) return undefined; // → start a fresh proxy + if (matches.length > 1) { throw new CliExitError( - `The running proxy session ${candidate.id} was started with a different .env.schema.`, - { - details: "Its placeholders and rules no longer match this directory's schema.", - suggestion: 'Restart it (or run `varlock proxy reload`), or use --new to start a separate proxy.', - }, + `Multiple proxy sessions match this directory: ${matches.map((s) => s.id).join(', ')}.`, + { suggestion: 'Pass --session to choose one, or --new to start a separate proxy.' }, ); } - return candidate; + return matches[0]; } /** @@ -703,6 +902,9 @@ async function applyTrustedReload(opts: { rules: next.proxyRules, egressMode: next.egressMode, }); + // Keep the varlock.internal endpoint serving the CURRENT child view, so a + // post-reload attach adopts the reloaded env, not the launch-time one. + opts.runtime.setSessionEnvPayloadJson(encodeSessionEnvPayload(buildSessionEnvPayload(next))); await updateProxySessionRecord(opts.session.uuid, { schemaFingerprint: next.schemaFingerprint, egressMode: next.egressMode, @@ -1025,29 +1227,24 @@ async function runAction(ctx: any) { const commandArgsOnly = commandToRunAsArgs.slice(1); const commandToRunStr = commandToRunAsArgs.join(' '); - const policy = await prepareProxyPolicy(ctx.values.path); - // Attach to a running proxy for this directory (a `proxy start` daemon) when // possible — its terminal handles approvals — instead of a new auto-deny proxy. // --new forces a fresh proxy. - const attachSession = ctx.values.new ? undefined : await resolveAttachSession(ctx, policy.schemaFingerprint); + const attachSession = ctx.values.new ? undefined : await resolveAttachSession(ctx); let session: ProxySessionRecord; + let payload: SessionEnvPayload; + /** Wire real values for redaction seeding: only a self-owned run holds them. */ + let redactionManagedItems: Array | undefined; let statsWriter: ReturnType | undefined; let cleanup: () => Promise; if (attachSession) { - // Use the daemon's authoritative view so the child's values are exactly what - // the running proxy expects: its placeholders for sensitive items, and its - // omitted keys withheld entirely. - for (const [key, placeholder] of Object.entries(attachSession.placeholderOverrides ?? {})) { - policy.resolvedEnv[key] = placeholder; - if (policy.serializedGraph.config[key]) policy.serializedGraph.config[key].value = placeholder; - } - for (const key of attachSession.omittedKeys ?? []) { - delete policy.resolvedEnv[key]; - delete policy.serializedGraph.config[key]; - } + // Adopt the running session's env: the owner resolved it in its own context + // (its shell overrides, its env selection), and attaching means running + // inside THAT session's env. No schema load, no resolution, and no unlock + // prompt happens on this path. + payload = await fetchSessionEnvPayload(attachSession); session = attachSession; // Register this `proxy run` process on the (shared daemon) session so ancestry // detection recognizes the agent as proxied even if it scrubs the env markers — @@ -1058,6 +1255,13 @@ async function runAction(ctx: any) { await removeProxySessionAttachment(session.uuid, process.pid).catch(() => undefined); }; } else { + // Self-owned one-shot: the single trusted resolve happens here — the only + // path in `proxy run` that touches resolvers or the encrypted cache. + const policy = await prepareProxyPolicy(ctx.values.path); + // Round-trip through the serializer so the common path exercises the exact + // encoding attach receives over the wire (parity can't silently rot). + payload = decodeSessionEnvPayload(encodeSessionEnvPayload(buildSessionEnvPayload(policy))); + redactionManagedItems = policy.proxyManagedItems; // Reload posture (see resolveReloadMode). A one-shot `proxy run` re-reads the // schema on its next invocation, so `auto` resolves to off here; the flag or // `@proxyConfig={reload="manual"}` can still opt this self-owned run in. @@ -1102,56 +1306,17 @@ async function runAction(ctx: any) { } const { injectVars, injectBlob } = applyInjectModeFromFlags(ctx); - const sessionEnv = getProxySessionExportEnv(session); - const fullInjectedEnv: NodeJS.ProcessEnv = { - ...process.env, - ...sessionEnv, - [PROXY_PARENT_PID_ENV_VAR]: String(process.pid), - ...(injectVars ? policy.resolvedEnv : {}), - __VARLOCK_RUN: '1', - ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(policy.serializedGraph) } : {}), - }; - - if (injectBlob && !injectVars && process.env._VARLOCK_ENV_KEY) { - fullInjectedEnv._VARLOCK_ENV_KEY = process.env._VARLOCK_ENV_KEY; - } - - // Per-stream TTY auto-detect (interactive terminal -> raw inherit so tools like `claude` - // work; piped/redirected -> redact). Shared with `varlock run` so they can't diverge. - const { redactStdout, redactStderr } = resolveStdoutRedaction({ + const commandProcess = spawnProxiedChild({ + payload, + session, + rawCommand, + commandArgsOnly, + injectVars, + injectBlob, redactStdoutFlag: ctx.values['redact-stdout'], - redactLogs: policy.serializedGraph.settings?.redactLogs ?? true, + redactionManagedItems, }); - // Seed the redaction map with the REAL values the proxy injects at the wire (on top of - // the schema's own secrets), so if the child ever echoes an injected value back it is - // still scrubbed. Only needed when we actually redact a stream. - if (redactStdout || redactStderr) { - const redactionGraph = { - ...policy.serializedGraph, - config: { ...policy.serializedGraph.config }, - }; - for (const managedItem of policy.proxyManagedItems) { - const schemaItem = policy.serializedGraph.config[managedItem.key]; - if (!schemaItem?.isSensitive) continue; - if (!managedItem.realValue || managedItem.realValue === schemaItem.value) continue; - redactionGraph.config[`__PROXY_REAL__${managedItem.key}`] = { - value: managedItem.realValue, - isSensitive: true, - }; - } - resetRedactionMap(redactionGraph); - } - - // An inherited stream (both false) yields raw TTY pass-through, same as stdio: 'inherit'. - const commandProcess = exec(rawCommand, commandArgsOnly, { - stdin: 'inherit', - stdout: redactStdout ? 'pipe' : 'inherit', - stderr: redactStderr ? 'pipe' : 'inherit', - env: fullInjectedEnv, - }); - pipeRedactedStreams(commandProcess, { redactStdout, redactStderr }); - if (commandProcess.pid && !attachSession) { await updateProxySessionRecord(session.uuid, { childPid: commandProcess.pid }); } diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index f33dad7f4..d82c1c3f6 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest'; import outdent from 'outdent'; import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; import { - computeProxyChildView, isCwdWithin, resolveReloadMode, createReloadKeypressHandler, + buildProxiedChildEnv, computeProxyChildView, isCwdWithin, resolveReloadMode, createReloadKeypressHandler, } from '../proxy.command.js'; async function loadGraph(envFile: string) { @@ -268,3 +268,82 @@ describe('@proxy nested rules=[...] form', () => { await expect(graph.getProxyRules()).rejects.toThrow(/unknown option "domain" in a rules entry/); }); }); + +describe('buildProxiedChildEnv', () => { + const PAYLOAD = { + env: { + API_KEY: 'vlk_placeholder_API_KEY_abc', + LOG_LEVEL: 'info', + }, + omittedKeys: ['ADMIN_TOKEN'], + serializedGraph: { + sources: [], + settings: {}, + config: { + API_KEY: { value: 'vlk_placeholder_API_KEY_abc', isSensitive: true }, + LOG_LEVEL: { value: 'info', isSensitive: false }, + }, + }, + } as any; + const SESSION_EXPORT = { HTTP_PROXY: 'http://127.0.0.1:9999', __VARLOCK_PROXY_CHILD: '1' }; + + test('payload values win over the launching shell (an accidentally exported real key never reaches the child)', () => { + const env = buildProxiedChildEnv({ + payload: PAYLOAD, + sessionExportEnv: SESSION_EXPORT, + parentPid: 123, + injectVars: true, + injectBlob: true, + baseEnv: { API_KEY: 'sk-REAL-oops', LOG_LEVEL: 'debug', PATH: '/usr/bin' }, + }); + expect(env.API_KEY).toBe('vlk_placeholder_API_KEY_abc'); + expect(env.LOG_LEVEL).toBe('info'); + // ambient OS env still flows through + expect(env.PATH).toBe('/usr/bin'); + // plumbing + markers land + expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9999'); + expect(env.__VARLOCK_PROXY_PARENT_PID).toBe('123'); + expect(env.__VARLOCK_RUN).toBe('1'); + expect(env.__VARLOCK_ENV).toBe(JSON.stringify(PAYLOAD.serializedGraph)); + }); + + test('omitted keys are absent even when the shell exports them', () => { + const env = buildProxiedChildEnv({ + payload: PAYLOAD, + sessionExportEnv: SESSION_EXPORT, + parentPid: 123, + injectVars: true, + injectBlob: true, + baseEnv: { ADMIN_TOKEN: 'real-admin-token-from-shell' }, + }); + expect('ADMIN_TOKEN' in env).toBe(false); + }); + + test('inject mode blob-only keeps _VARLOCK_ENV_KEY and skips plain vars', () => { + const env = buildProxiedChildEnv({ + payload: PAYLOAD, + sessionExportEnv: SESSION_EXPORT, + parentPid: 1, + injectVars: false, + injectBlob: true, + baseEnv: { _VARLOCK_ENV_KEY: 'enc-key', API_KEY: 'ambient' }, + }); + // no var injection: the ambient value survives (blob consumers resolve via the graph) + expect(env.API_KEY).toBe('ambient'); + expect(env._VARLOCK_ENV_KEY).toBe('enc-key'); + expect(env.__VARLOCK_ENV).toBeDefined(); + }); + + test('inject mode vars-only skips the blob', () => { + const env = buildProxiedChildEnv({ + payload: PAYLOAD, + sessionExportEnv: SESSION_EXPORT, + parentPid: 1, + injectVars: true, + injectBlob: false, + baseEnv: {}, + }); + expect(env.API_KEY).toBe('vlk_placeholder_API_KEY_abc'); + expect(env.__VARLOCK_ENV).toBeUndefined(); + }); +}); diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index 7cbfaaa23..deb389c64 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -458,3 +458,107 @@ describe('startLocalProxyRuntime', () => { }); }); }); + +describe('varlock.internal session-env endpoint', () => { + const TOKEN = 'test-session-token-uuid'; + const PAYLOAD_JSON = JSON.stringify({ + env: { FOO: 'bar', API_KEY: 'vlk_placeholder_API_KEY_abc' }, + omittedKeys: ['ADMIN_TOKEN'], + serializedGraph: { settings: {}, config: {}, sources: [] }, + }); + + async function startWithEndpoint(opts?: { egressMode?: 'permissive' | 'strict'; skipPayload?: boolean }) { + const activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [], + egressMode: opts?.egressMode ?? 'permissive', + internalEndpointToken: TOKEN, + onActivity: (a) => activities.push(a), + }); + if (!opts?.skipPayload) runtime.setSessionEnvPayloadJson(PAYLOAD_JSON); + return { runtime, activities }; + } + + test('serves the current payload with a valid token, without reporting egress activity', async () => { + const { runtime, activities } = await startWithEndpoint(); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/json'); + expect(JSON.parse(res.body)).toEqual(JSON.parse(PAYLOAD_JSON)); + // control plane, not traffic: never counted as egress + expect(activities).toEqual([]); + await runtime.stop(); + }); + + test('serves the LATEST payload after a swap (reload freshness)', async () => { + const { runtime } = await startWithEndpoint(); + const updated = JSON.stringify({ env: { FOO: 'post-reload' }, omittedKeys: [], serializedGraph: { config: {} } }); + runtime.setSessionEnvPayloadJson(updated); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(JSON.parse(res.body).env.FOO).toBe('post-reload'); + await runtime.stop(); + }); + + test('refuses a missing or wrong token with 403 and serves nothing', async () => { + const { runtime } = await startWithEndpoint(); + const noToken = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + }); + expect(noToken.statusCode).toBe(403); + expect(noToken.body).not.toContain('API_KEY'); + const wrongToken = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': 'wrong-token-same-length--', + }); + expect(wrongToken.statusCode).toBe(403); + await runtime.stop(); + }); + + test('403s when the endpoint is not enabled, even with some token', async () => { + const runtime = await startLocalProxyRuntime({ managedItems: [], rules: [], egressMode: 'permissive' }); + runtime.setSessionEnvPayloadJson(PAYLOAD_JSON); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(res.statusCode).toBe(403); + await runtime.stop(); + }); + + test('404s an unknown internal path (token first: only authenticated callers learn paths)', async () => { + const { runtime } = await startWithEndpoint(); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/nope', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(res.statusCode).toBe(404); + await runtime.stop(); + }); + + test('503s when the payload has not been set yet', async () => { + const { runtime } = await startWithEndpoint({ skipPayload: true }); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(res.statusCode).toBe(503); + await runtime.stop(); + }); + + test('reachable under strict egress (handled before the egress gate)', async () => { + const { runtime } = await startWithEndpoint({ egressMode: 'strict' }); + const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { + host: 'varlock.internal', + 'x-varlock-proxy-token': TOKEN, + }); + expect(res.statusCode).toBe(200); + await runtime.stop(); + }); +}); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 4a42205d1..908973409 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import { mkdtemp, rm, writeFile, } from 'node:fs/promises'; @@ -19,6 +20,9 @@ import { createEphemeralCa, createHostCert } from './cert-authority'; import { describeRule, evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, } from './policy'; +import { + PROXY_TOKEN_HEADER, SESSION_ENV_ENDPOINT_PATH, VARLOCK_INTERNAL_HOST, +} from './session-env-payload'; import type { ProxyApprovalEach, ProxyEgressMode, ProxyManagedItem, ProxyRule, } from './types'; @@ -40,6 +44,12 @@ export type ProxyRuntimeContext = { * snapshot they already resolved. The proxy address and CA are unchanged. */ reconfigure: (next: ProxyReconfigureInput) => void; + /** + * Set/replace the encoded session-env payload the `varlock.internal` endpoint + * serves (see `internalEndpointToken`). Called once after startup and again on + * every reload so attach fetches are always current. + */ + setSessionEnvPayloadJson: (payloadJson: string) => void; stop: () => Promise; }; @@ -67,6 +77,16 @@ export type StartLocalProxyRuntimeInput = { * (deny on timeout/error). Absent ⇒ require-approval requests are denied. */ approvalProvider?: ApprovalProvider; + /** + * Enables the `varlock.internal` internal endpoint, which serves the current + * session-env payload (child-view env + graph; never wire real values) so an + * attaching `proxy run` can adopt this session's env without resolving + * anything itself. Requests to the internal host are answered by the proxy, + * never forwarded upstream, and not reported as egress activity. The token is + * the session token from the 0600 record, so this gates at same-uid — the same + * trust level as the record itself, not a hard boundary. + */ + internalEndpointToken?: string; }; type HostInfo = { host: string, port: number }; @@ -277,6 +297,15 @@ function respondBlocked( if (teardown) res.socket?.destroy(); } +/** Constant-time token comparison (length leak is fine; the token is a uuid, not a password). */ +function tokenMatches(provided: unknown, expected: string): boolean { + if (typeof provided !== 'string') return false; + const providedBuf = Buffer.from(provided); + const expectedBuf = Buffer.from(expected); + if (providedBuf.length !== expectedBuf.length) return false; + return timingSafeEqual(providedBuf, expectedBuf); +} + /** Transport-specific inputs for a proxied request, shared by the MITM-tunnel and * absolute-form (plain http) handlers so the policy/approval/injection/forwarding * logic lives in one place. */ @@ -561,6 +590,7 @@ export async function startLocalProxyRuntime({ onActivity, onResponse, approvalProvider, + internalEndpointToken, }: StartLocalProxyRuntimeInput): Promise { // Mutable so `reconfigure` can hot-swap the enforced policy on a live proxy. // The request handlers below close over these bindings, so reassigning them @@ -568,6 +598,8 @@ export async function startLocalProxyRuntime({ let managedItems = initialManagedItems; let rules = initialRules; let egressMode = initialEgressMode; + // Set via setSessionEnvPayloadJson right after startup (and on each reload). + let sessionEnvPayloadJson: string | undefined; // Only the public CA cert is written to disk (for child trust). Private keys // — the CA's and every per-host leaf's — stay in memory; see cert-authority.ts. const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); @@ -577,6 +609,35 @@ export async function startLocalProxyRuntime({ await writeFile(caCertPath, ca.certPem, 'utf8'); await writeFile(combinedCaPath, `${ca.certPem}\n${tls.rootCertificates.join('\n')}\n`, 'utf8'); + // Internal control endpoint: requests to the magic internal host are answered + // by the proxy itself (an attaching `proxy run` fetches the session env here). + // Handled before any egress/rule evaluation, never forwarded upstream, and + // deliberately not reported as egress activity (it is control plane, not + // traffic). Fail order: token first (an unauthenticated caller learns nothing, + // not even which paths exist), then path. + const handleInternalRequest = ( + req: http.IncomingMessage, + res: http.ServerResponse, + t: ProxiedRequestTransport, + ) => { + if (!internalEndpointToken || !tokenMatches(req.headers[PROXY_TOKEN_HEADER], internalEndpointToken)) { + respondBlocked(res, 403, 'varlock proxy: invalid or missing session token', t.tunnelTeardown); + return; + } + if (t.method !== 'GET' || t.pathOnly !== SESSION_ENV_ENDPOINT_PATH) { + respondBlocked(res, 404, 'varlock proxy: unknown internal endpoint', t.tunnelTeardown); + return; + } + if (!sessionEnvPayloadJson) { + respondBlocked(res, 503, 'varlock proxy: session env not ready yet', t.tunnelTeardown); + return; + } + try { + res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); + res.end(sessionEnvPayloadJson); + } catch { /* client went away */ } + }; + // Shared request pipeline for both transports (MITM tunnel + absolute-form http): // egress gate → per-call policy (block) → cleartext guard → approval gate → // scrub+inject → forward upstream (verified identity) → scrub response. Every @@ -586,6 +647,11 @@ export async function startLocalProxyRuntime({ res: http.ServerResponse, t: ProxiedRequestTransport, ) => { + if (normalizeHost(t.host) === VARLOCK_INTERNAL_HOST) { + handleInternalRequest(req, res, t); + return; + } + const baseActivity = { host: t.host, method: t.method, path: t.pathOnly, url: t.requestTarget, }; @@ -933,6 +999,9 @@ export async function startLocalProxyRuntime({ CURL_CA_BUNDLE: combinedCaPath, GIT_SSL_CAINFO: combinedCaPath, }, + setSessionEnvPayloadJson: (payloadJson) => { + sessionEnvPayloadJson = payloadJson; + }, reconfigure: (next) => { managedItems = next.managedItems; rules = next.rules; diff --git a/packages/varlock/src/proxy/session-env-payload.test.ts b/packages/varlock/src/proxy/session-env-payload.test.ts new file mode 100644 index 000000000..d165b7e80 --- /dev/null +++ b/packages/varlock/src/proxy/session-env-payload.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildSessionEnvPayload, + decodeSessionEnvPayload, + encodeSessionEnvPayload, +} from './session-env-payload'; + +const GRAPH = { + sources: [], + settings: { redactLogs: true }, + config: { + API_KEY: { value: 'vlk_placeholder_API_KEY_abc', isSensitive: true }, + LOG_LEVEL: { value: 'info', isSensitive: false }, + }, +} as any; + +describe('session env payload encode/decode', () => { + test('round-trips the payload the one-shot path uses', () => { + const payload = buildSessionEnvPayload({ + resolvedEnv: { API_KEY: 'vlk_placeholder_API_KEY_abc', LOG_LEVEL: 'info' }, + omittedKeys: ['ADMIN_TOKEN'], + serializedGraph: GRAPH, + }); + const decoded = decodeSessionEnvPayload(encodeSessionEnvPayload(payload)); + expect(decoded).toEqual(payload); + }); + + test.each([ + ['not JSON', 'nope{'], + ['not an object', '"str"'], + ['missing env', JSON.stringify({ omittedKeys: [], serializedGraph: { config: {} } })], + ['non-string env value', JSON.stringify({ env: { A: 1 }, omittedKeys: [], serializedGraph: { config: {} } })], + ['omittedKeys not an array', JSON.stringify({ env: {}, omittedKeys: 'x', serializedGraph: { config: {} } })], + ['omittedKeys with non-strings', JSON.stringify({ env: {}, omittedKeys: [1], serializedGraph: { config: {} } })], + ['serializedGraph missing config', JSON.stringify({ env: {}, omittedKeys: [], serializedGraph: {} })], + ])('rejects a malformed payload: %s', (_label, raw) => { + expect(() => decodeSessionEnvPayload(raw)).toThrow(); + }); +}); diff --git a/packages/varlock/src/proxy/session-env-payload.ts b/packages/varlock/src/proxy/session-env-payload.ts new file mode 100644 index 000000000..72d24eb4d --- /dev/null +++ b/packages/varlock/src/proxy/session-env-payload.ts @@ -0,0 +1,88 @@ +import _ from '@env-spec/utils/my-dash'; +import type { SerializedEnvGraph } from '../env-graph'; + +/** + * The magic internal host the proxy runtime answers itself (never forwarded + * upstream). Rides the data plane: the proxy port is the one channel that must + * exist wherever proxied work happens (attach today; sandboxed and remote + * workloads later). + */ +export const VARLOCK_INTERNAL_HOST = 'varlock.internal'; +export const SESSION_ENV_ENDPOINT_PATH = '/session-env'; +/** Session token header for internal endpoint requests (lowercase: node lowercases incoming header names). */ +export const PROXY_TOKEN_HEADER = 'x-varlock-proxy-token'; + +/** + * The complete child view a proxy session hands to whatever spawns a proxied + * child: the env the child should see (placeholders for sensitive items, real + * values for non-secrets and `@proxy=passthrough`), the keys withheld entirely, + * and the child-view serialized graph (for the `__VARLOCK_ENV` blob and stdout + * redaction). Never contains wire-injection real values; those stay in the + * session owner's memory and only ever touch the network boundary. + * + * One producer (`buildSessionEnvPayload`, session-owner side) and one consumer + * (`spawnProxiedChild`). A one-shot `proxy run` builds it in-process; an + * attaching `proxy run` fetches it from the owner via the `varlock.internal` + * endpoint. Attach ADOPTS this env verbatim: the owner resolved it in its own + * context (its shell overrides, its env selection), and attaching means running + * inside THAT session's env, not re-resolving in the attaching shell's context. + */ +export type SessionEnvPayload = { + env: Record; + omittedKeys: Array; + serializedGraph: SerializedEnvGraph; +}; + +/** Build the payload from a prepared proxy policy (the child-view env/graph it already computed). */ +export function buildSessionEnvPayload(policy: { + resolvedEnv: Record; + omittedKeys: Array; + serializedGraph: SerializedEnvGraph; +}): SessionEnvPayload { + return { + env: policy.resolvedEnv, + omittedKeys: policy.omittedKeys, + serializedGraph: policy.serializedGraph, + }; +} + +export function encodeSessionEnvPayload(payload: SessionEnvPayload): string { + return JSON.stringify(payload); +} + +/** + * Parse + shape-check a payload. The one-shot path round-trips through + * encode/decode too, so every ordinary `proxy run` exercises the exact wire + * encoding: a non-JSON-safe value fails loudly in the common path instead of + * rotting in the rarely-exercised attach path. + */ +export function decodeSessionEnvPayload(raw: string): SessionEnvPayload { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('session env payload is not valid JSON'); + } + if (!_.isPlainObject(parsed)) throw new Error('session env payload is not an object'); + const candidate = parsed as Record; + + if (!_.isPlainObject(candidate.env)) throw new Error('session env payload `env` is not an object'); + for (const [key, value] of Object.entries(candidate.env as Record)) { + if (!_.isString(value)) throw new Error(`session env payload \`env.${key}\` is not a string`); + } + + if (!Array.isArray(candidate.omittedKeys) || candidate.omittedKeys.some((k) => !_.isString(k))) { + throw new Error('session env payload `omittedKeys` is not an array of strings'); + } + + if (!_.isPlainObject(candidate.serializedGraph) + || !_.isPlainObject((candidate.serializedGraph as Record).config)) { + throw new Error('session env payload `serializedGraph` is missing or malformed'); + } + + return { + env: candidate.env as Record, + omittedKeys: candidate.omittedKeys as Array, + serializedGraph: candidate.serializedGraph as SerializedEnvGraph, + }; +} From 762466b2fc5047ce4327dbd39b8fa1b67f23c0d7 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 20:33:22 -0700 Subject: [PATCH 59/64] refactor(blob): flatten override provenance to a plain overrideKeys field The injected __VARLOCK_ENV blob's compat model is additive fields plus ignore-unknown (the graph itself carries no version field), so wrapping the override-keys list in a {source, version} sidecar object was ceremony that protected nothing. Write it as a plain overrideKeys field on the serialized graph; the reader still tolerates the older __varlockOverrideMeta / __varlockRunMeta wrappers (array only, no source/version validation) so blobs from older producers keep working. --- .../varlock/src/env-graph/lib/env-graph.ts | 8 +-- .../test/varlock-reserved-keys.test.ts | 2 +- .../src/lib/injected-env-provenance.ts | 71 +++++++------------ packages/varlock/src/lib/load-graph.ts | 8 +-- .../lib/test/injected-env-provenance.test.ts | 56 ++++++--------- 5 files changed, 57 insertions(+), 88 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 3cdd2683e..1c3d29fb8 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -25,7 +25,7 @@ import { runWithResolutionContext, getResolutionContext } from './resolution-con import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info'; import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; import { isVarlockReservedKey } from './reserved-vars'; -import { buildOverrideProvenanceMetadata, type OverrideProvenanceMetadata } from '../../lib/injected-env-provenance'; +import { normalizeOverrideKeys } from '../../lib/injected-env-provenance'; import { generateProxyPlaceholderForItem } from '../../proxy/placeholder'; import { PROXY_APPROVAL_EACH_VALUES, @@ -76,8 +76,8 @@ export type SerializedEnvGraph = { /** true = used only by varlock, not injected into the app. Only present in inspection output (never in the blob). */ isInternal?: boolean; }>; - /** provenance metadata for process.env overrides across nested invocations */ - __varlockOverrideMeta?: OverrideProvenanceMetadata; + /** Keys that were genuine process.env overrides at this invocation, so nested varlock invocations re-apply exactly those (and nothing else) as overrides. */ + overrideKeys?: Array; /** Present only when config has errors — consumers can check `if (data.errors)` */ errors?: SerializedEnvGraphErrors; }; @@ -775,7 +775,7 @@ export class EnvGraph { // list would mirror every env var (PATH, HOME, ...) — pure noise that also leaks the // caller's full env var name list into the blob. Reserved _VARLOCK_* keys configure // varlock itself and are never overrides, so exclude them even if defined in the schema. - serializedGraph.__varlockOverrideMeta = buildOverrideProvenanceMetadata( + serializedGraph.overrideKeys = normalizeOverrideKeys( Object.keys(this.overrideValues).filter( (k) => k in this.configSchema && !isVarlockReservedKey(k), ), diff --git a/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts b/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts index 0a2f5311f..0270ab31e 100644 --- a/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts +++ b/packages/varlock/src/env-graph/test/varlock-reserved-keys.test.ts @@ -82,7 +82,7 @@ describe('serialized graph excludes reserved _VARLOCK_ keys', () => { _VARLOCK_REDACT_STDOUT: 'true', // reserved infra key → must not be recorded }, ); - const overrideKeys = g.getSerializedGraph().__varlockOverrideMeta?.overrideKeys ?? []; + const overrideKeys = g.getSerializedGraph().overrideKeys ?? []; expect(overrideKeys).toContain('FOO'); expect(overrideKeys).not.toContain('PATH'); diff --git a/packages/varlock/src/lib/injected-env-provenance.ts b/packages/varlock/src/lib/injected-env-provenance.ts index 3eece5441..41738d5c6 100644 --- a/packages/varlock/src/lib/injected-env-provenance.ts +++ b/packages/varlock/src/lib/injected-env-provenance.ts @@ -1,39 +1,22 @@ -import type { SerializedEnvGraph } from '../env-graph'; - -const OVERRIDE_PROVENANCE_SOURCE = 'varlock'; -const OVERRIDE_PROVENANCE_VERSION = 1; - -export type OverrideProvenanceMetadata = { - source: typeof OVERRIDE_PROVENANCE_SOURCE; - version: typeof OVERRIDE_PROVENANCE_VERSION; - overrideKeys: Array; -}; - -type SerializedGraphWithRunMetadata = SerializedEnvGraph & { - __varlockOverrideMeta?: OverrideProvenanceMetadata; - /** Legacy field written by previous implementation in run.command.ts */ - __varlockRunMeta?: { - source: 'varlock-run'; - version: 1; - overrideKeys: Array; - }; -}; - -function normalizeOverrideKeys(overrideKeys: Array) { +/** + * Which env vars were genuine user overrides (present in the caller's env AND + * defined in the schema) at the original varlock invocation. Carried in the + * injected `__VARLOCK_ENV` blob as a plain `overrideKeys` field so nested + * varlock invocations re-apply exactly those as overrides, and nothing else: + * parent-injected values must not shadow fresh resolution. + * + * Plain field + ignore-unknown is the blob's compatibility model (the graph + * itself carries no version field). Older producers wrapped the same list in a + * `__varlockOverrideMeta` / `__varlockRunMeta` object; we still read the array + * out of those, without the source/version ceremony. + */ + +export function normalizeOverrideKeys(overrideKeys: Array): Array { return [...new Set(overrideKeys.filter((k) => typeof k === 'string'))]; } -export function buildOverrideProvenanceMetadata( - overrideKeys: Array, -): OverrideProvenanceMetadata { - return { - source: OVERRIDE_PROVENANCE_SOURCE, - version: OVERRIDE_PROVENANCE_VERSION, - overrideKeys: normalizeOverrideKeys(overrideKeys), - }; -} - -export function parseOverrideProvenanceMetadata(blob?: string): OverrideProvenanceMetadata | undefined { +/** Extract the override-keys list from an injected `__VARLOCK_ENV` blob, if present. */ +export function parseBlobOverrideKeys(blob?: string): Array | undefined { if (!blob) return undefined; let parsed: unknown; @@ -42,21 +25,19 @@ export function parseOverrideProvenanceMetadata(blob?: string): OverrideProvenan } catch { return undefined; } - if (!parsed || typeof parsed !== 'object') return undefined; - const parsedGraph = parsed as SerializedGraphWithRunMetadata; - const metadata = parsedGraph.__varlockOverrideMeta ?? parsedGraph.__varlockRunMeta; - if (!metadata || typeof metadata !== 'object') return undefined; - if (metadata.source !== OVERRIDE_PROVENANCE_SOURCE && metadata.source !== 'varlock-run') return undefined; - if (metadata.version !== OVERRIDE_PROVENANCE_VERSION) return undefined; - if (!Array.isArray(metadata.overrideKeys)) return undefined; - if (metadata.overrideKeys.some((k) => typeof k !== 'string')) return undefined; - return { - source: OVERRIDE_PROVENANCE_SOURCE, - version: OVERRIDE_PROVENANCE_VERSION, - overrideKeys: normalizeOverrideKeys(metadata.overrideKeys), + const graph = parsed as { + overrideKeys?: unknown; + __varlockOverrideMeta?: { overrideKeys?: unknown }; + __varlockRunMeta?: { overrideKeys?: unknown }; }; + const keys = graph.overrideKeys + ?? graph.__varlockOverrideMeta?.overrideKeys + ?? graph.__varlockRunMeta?.overrideKeys; + if (!Array.isArray(keys)) return undefined; + + return normalizeOverrideKeys(keys as Array); } export function selectOverrideValuesFromEnv( diff --git a/packages/varlock/src/lib/load-graph.ts b/packages/varlock/src/lib/load-graph.ts index f59df4fa5..5845ec53d 100644 --- a/packages/varlock/src/lib/load-graph.ts +++ b/packages/varlock/src/lib/load-graph.ts @@ -8,7 +8,7 @@ import { captureUsageContextFromEnvGraph, captureTelemetryGraphLoadFailure } fro import { runWithWorkspaceInfo } from './workspace-utils'; import { readVarlockPackageJsonConfig } from './package-json-config'; import { createDebug } from './debug'; -import { parseOverrideProvenanceMetadata, selectOverrideValuesFromEnv } from './injected-env-provenance'; +import { parseBlobOverrideKeys, selectOverrideValuesFromEnv } from './injected-env-provenance'; import { getActiveProxySession, getProxyResolutionViewForEnv } from '../proxy/session-registry'; import { PROXY_CHILD_ENV_VAR } from '../proxy/env-vars'; import { enforceProxySchemaFingerprint } from '../cli/helpers/proxy-schema-fingerprint'; @@ -16,9 +16,9 @@ import { enforceProxySchemaFingerprint } from '../cli/helpers/proxy-schema-finge const debug = createDebug('varlock:load'); function getGraphEnvOverridesFromRuntimeEnv() { - const provenance = parseOverrideProvenanceMetadata(process.env.__VARLOCK_ENV); - if (!provenance) return undefined; - return selectOverrideValuesFromEnv(process.env, provenance.overrideKeys); + const overrideKeys = parseBlobOverrideKeys(process.env.__VARLOCK_ENV); + if (!overrideKeys) return undefined; + return selectOverrideValuesFromEnv(process.env, overrideKeys); } function normalizePkgLoadPath(pkgLoadPath: string | Array): Array { diff --git a/packages/varlock/src/lib/test/injected-env-provenance.test.ts b/packages/varlock/src/lib/test/injected-env-provenance.test.ts index 7f9fc5501..27f8efcab 100644 --- a/packages/varlock/src/lib/test/injected-env-provenance.test.ts +++ b/packages/varlock/src/lib/test/injected-env-provenance.test.ts @@ -1,56 +1,44 @@ import { describe, expect, test } from 'vitest'; import { - buildOverrideProvenanceMetadata, - parseOverrideProvenanceMetadata, + normalizeOverrideKeys, + parseBlobOverrideKeys, selectOverrideValuesFromEnv, } from '../injected-env-provenance'; -describe('injected env provenance', () => { - test('builds override metadata', () => { - const metadata = buildOverrideProvenanceMetadata(['A', 'B', 'A']); - expect(metadata.source).toBe('varlock'); - expect(metadata.version).toBe(1); - expect(metadata.overrideKeys).toEqual(['A', 'B']); +describe('injected env override keys', () => { + test('normalizes (dedupes, strings only)', () => { + expect(normalizeOverrideKeys(['A', 'B', 'A', 1 as any])).toEqual(['A', 'B']); }); - test('parses current metadata shape', () => { - const parsed = parseOverrideProvenanceMetadata(JSON.stringify({ - __varlockOverrideMeta: { - source: 'varlock', - version: 1, - overrideKeys: ['A', 'B', 'A'], - }, + test('parses the plain overrideKeys field', () => { + const parsed = parseBlobOverrideKeys(JSON.stringify({ + overrideKeys: ['A', 'B', 'A'], config: {}, settings: {}, sources: [], })); - expect(parsed?.source).toBe('varlock'); - expect(parsed?.version).toBe(1); - expect(parsed?.overrideKeys).toEqual(['A', 'B']); + expect(parsed).toEqual(['A', 'B']); }); - test('parses legacy run metadata shape', () => { - const parsed = parseOverrideProvenanceMetadata(JSON.stringify({ - __varlockRunMeta: { - source: 'varlock-run', - version: 1, - overrideKeys: ['A'], - }, + test('still reads the list out of older wrapped shapes', () => { + expect(parseBlobOverrideKeys(JSON.stringify({ + __varlockOverrideMeta: { source: 'varlock', version: 1, overrideKeys: ['A'] }, config: {}, - settings: {}, - sources: [], - })); - expect(parsed?.source).toBe('varlock'); - expect(parsed?.version).toBe(1); - expect(parsed?.overrideKeys).toEqual(['A']); + }))).toEqual(['A']); + expect(parseBlobOverrideKeys(JSON.stringify({ + __varlockRunMeta: { source: 'varlock-run', version: 1, overrideKeys: ['B'] }, + config: {}, + }))).toEqual(['B']); }); test('returns undefined for malformed blob', () => { - expect(parseOverrideProvenanceMetadata('{not-json')).toBeUndefined(); + expect(parseBlobOverrideKeys('{not-json')).toBeUndefined(); + expect(parseBlobOverrideKeys('"str"')).toBeUndefined(); + expect(parseBlobOverrideKeys(JSON.stringify({ overrideKeys: 'not-an-array' }))).toBeUndefined(); }); - test('returns undefined for missing metadata', () => { - expect(parseOverrideProvenanceMetadata(JSON.stringify({ + test('returns undefined when no override keys are present', () => { + expect(parseBlobOverrideKeys(JSON.stringify({ config: {}, settings: {}, sources: [], From bdfee80fe560b1a16669a048c1077d18176b87e7 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 21:14:01 -0700 Subject: [PATCH 60/64] feat(proxy): harden the varlock.internal control endpoint - dedicated endpointToken credential, separate from the session uuid: the uuid is a displayed identifier (status output, child env) while the token is never printed anywhere, so pasted output or a shared screen can't leak endpoint access - failed token/peer checks are surfaced in the owner's log (throttled) so a probing process on the machine is visible - loopback-only peer assertion on both the absolute-form and CONNECT paths: redundant while the listener binds 127.0.0.1, but a future non-loopback data-plane bind (sandbox bridging) can't silently expose the control plane - when a served payload includes passthrough secrets, say so in the live log (real values leaving the owner should be visible) --- .../varlock/src/cli/commands/proxy.command.ts | 67 ++++++++++++++++--- .../cli/commands/test/proxy.command.test.ts | 22 ++++++ .../varlock/src/proxy/runtime-proxy.test.ts | 28 ++++++-- packages/varlock/src/proxy/runtime-proxy.ts | 60 ++++++++++++++--- .../varlock/src/proxy/session-registry.ts | 9 +++ 5 files changed, 163 insertions(+), 23 deletions(-) diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index 3f33fb8ac..e4ee36cbd 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { randomUUID } from 'node:crypto'; import { watch as fsWatch, existsSync, statSync } from 'node:fs'; import { request as httpRequest } from 'node:http'; @@ -55,6 +56,7 @@ import { type ProxyReloadResult, } from '../../proxy/reload-channel'; import { isInProxyContext } from '../helpers/proxy-context-guard'; +import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; import { buildSessionEnvPayload, decodeSessionEnvPayload, @@ -409,7 +411,13 @@ export function buildProxiedChildEnv(opts: { [PROXY_PARENT_PID_ENV_VAR]: String(opts.parentPid), ...(opts.injectVars ? opts.payload.env : {}), __VARLOCK_RUN: '1', - ...(opts.injectBlob ? { __VARLOCK_ENV: JSON.stringify(opts.payload.serializedGraph) } : {}), + // honors @encryptInjectedEnv in blob-only mode; reuses/forwards an ambient key + ...buildInjectedBlobEnv({ + serializedGraph: opts.payload.serializedGraph, + injectVars: opts.injectVars, + injectBlob: opts.injectBlob, + ambientEnvKey: opts.baseEnv._VARLOCK_ENV_KEY, + }), }; // An omitted key must be absent from the child env entirely, even when the @@ -418,13 +426,20 @@ export function buildProxiedChildEnv(opts: { delete fullInjectedEnv[key]; } - if (opts.injectBlob && !opts.injectVars && opts.baseEnv._VARLOCK_ENV_KEY) { - fullInjectedEnv._VARLOCK_ENV_KEY = opts.baseEnv._VARLOCK_ENV_KEY; - } - return fullInjectedEnv; } +/** + * Count of sensitive items whose REAL value the child (and the session-env + * payload) carries: `@proxy=passthrough` items. Placeholdered items are in + * `placeholderByKey`; omitted ones are already deleted from the graph. + */ +function countPassthroughSecrets(policy: PreparedProxyPolicy): number { + return Object.entries(policy.serializedGraph.config as Record) + .filter(([key, item]) => item.isSensitive && !(key in policy.placeholderByKey)) + .length; +} + /** * Spawn the proxied child from a session-env payload: the ONLY consumer of the * payload, shared by one-shot (in-memory payload) and attach (fetched payload) @@ -519,6 +534,9 @@ async function fetchSessionEnvPayload(session: ProxySessionRecord): Promise((resolve, reject) => { // `timer` and `req` reference each other (timer destroys req; req's handlers @@ -533,7 +551,7 @@ async function fetchSessionEnvPayload(session: ProxySessionRecord): Promise { if (res.statusCode !== 200) { @@ -683,6 +701,11 @@ async function createRuntimeAndSession(opts: { grantStore?: ApprovalGrantStore; }> { const identity = await reserveProxySessionIdentity(); + // Bearer credential for the varlock.internal endpoint. Separate from the uuid + // on purpose: the uuid is a DISPLAYED identifier (status output, child env), + // this token is never printed anywhere (0600 record + owner memory only). + const endpointToken = randomUUID(); + let lastEndpointAuthWarnAt = 0; const statsWriter = createSessionStatsWriter(identity.uuid, EMPTY_PROXY_SESSION_STATS); // When grants are enabled, a session/duration approval is remembered so future // matching requests auto-approve without re-prompting (the seam the phone @@ -715,11 +738,33 @@ async function createRuntimeAndSession(opts: { onResponse: opts.logRequests ? (info) => emitProxyLog(formatProxyResponseLog(info)) : undefined, - internalEndpointToken: identity.uuid, + internalEndpoint: { + token: endpointToken, + onAuthFailure: () => { + // Throttled: a misbehaving prober shouldn't be able to flood the owner's log. + if (Date.now() - lastEndpointAuthWarnAt < 5000) return; + lastEndpointAuthWarnAt = Date.now(); + emitProxyLog( + '⚠️ Refused a request to this session\'s control endpoint (bad or missing token). ' + + 'Another process on this machine may be probing the proxy.', + ); + }, + onServed: (meta) => { + // Visibility when real secrets leave the owner: placeholders/non-secrets are + // routine (the attach watcher already logs the client), passthrough is not. + if (!meta?.passthroughCount) return; + emitProxyLog( + `${ansis.green('⊕')} served session env to an attaching client ${ansis.dim(`(includes ${meta.passthroughCount} passthrough secret${meta.passthroughCount === 1 ? '' : 's'})`)}`, + ); + }, + }, }); // Serve the child view to attaching `proxy run` processes (adopt semantics); // applyTrustedReload re-sets this after each reload so fetches stay current. - runtime.setSessionEnvPayloadJson(encodeSessionEnvPayload(buildSessionEnvPayload(opts.policy))); + runtime.setSessionEnvPayloadJson( + encodeSessionEnvPayload(buildSessionEnvPayload(opts.policy)), + { passthroughCount: countPassthroughSecrets(opts.policy) }, + ); const session = await createProxySessionRecord({ id: identity.id, uuid: identity.uuid, @@ -727,6 +772,7 @@ async function createRuntimeAndSession(opts: { cwd: process.cwd(), startedAt: now, egressMode: opts.policy.egressMode, + endpointToken, schemaFingerprint: opts.policy.schemaFingerprint, placeholderOverrides: opts.policy.placeholderByKey, ...(opts.policy.omittedKeys.length ? { omittedKeys: opts.policy.omittedKeys } : {}), @@ -904,7 +950,10 @@ async function applyTrustedReload(opts: { }); // Keep the varlock.internal endpoint serving the CURRENT child view, so a // post-reload attach adopts the reloaded env, not the launch-time one. - opts.runtime.setSessionEnvPayloadJson(encodeSessionEnvPayload(buildSessionEnvPayload(next))); + opts.runtime.setSessionEnvPayloadJson( + encodeSessionEnvPayload(buildSessionEnvPayload(next)), + { passthroughCount: countPassthroughSecrets(next) }, + ); await updateProxySessionRecord(opts.session.uuid, { schemaFingerprint: next.schemaFingerprint, egressMode: next.egressMode, diff --git a/packages/varlock/src/cli/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts index d82c1c3f6..e1c79850b 100644 --- a/packages/varlock/src/cli/commands/test/proxy.command.test.ts +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -334,6 +334,28 @@ describe('buildProxiedChildEnv', () => { expect(env.__VARLOCK_ENV).toBeDefined(); }); + test('blob-only mode honors @encryptInjectedEnv: blob encrypted, ephemeral key alongside', async () => { + const { isEncryptedBlob, decryptEnvBlobSync } = await import('../../../runtime/crypto'); + const payload = { + ...PAYLOAD, + serializedGraph: { ...PAYLOAD.serializedGraph, settings: { encryptInjectedEnv: true } }, + }; + const env = buildProxiedChildEnv({ + payload, + sessionExportEnv: SESSION_EXPORT, + parentPid: 1, + injectVars: false, + injectBlob: true, + baseEnv: {}, + }); + expect(isEncryptedBlob(env.__VARLOCK_ENV!)).toBe(true); + expect(env._VARLOCK_ENV_KEY).toBeDefined(); + // the runtime can decrypt it transparently + const decrypted = JSON.parse(decryptEnvBlobSync(env.__VARLOCK_ENV!, env._VARLOCK_ENV_KEY!)); + expect(decrypted.settings.encryptInjectedEnv).toBe(true); + expect(decrypted.config.API_KEY.value).toBe('vlk_placeholder_API_KEY_abc'); + }); + test('inject mode vars-only skips the blob', () => { const env = buildProxiedChildEnv({ payload: PAYLOAD, diff --git a/packages/varlock/src/proxy/runtime-proxy.test.ts b/packages/varlock/src/proxy/runtime-proxy.test.ts index deb389c64..4887bae7c 100644 --- a/packages/varlock/src/proxy/runtime-proxy.test.ts +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -469,19 +469,29 @@ describe('varlock.internal session-env endpoint', () => { async function startWithEndpoint(opts?: { egressMode?: 'permissive' | 'strict'; skipPayload?: boolean }) { const activities: Array = []; + const authFailures: Array = []; + const served: Array<{ passthroughCount?: number } | undefined> = []; const runtime = await startLocalProxyRuntime({ managedItems: [], rules: [], egressMode: opts?.egressMode ?? 'permissive', - internalEndpointToken: TOKEN, + internalEndpoint: { + token: TOKEN, + onAuthFailure: () => authFailures.push(true), + onServed: (meta) => served.push(meta), + }, onActivity: (a) => activities.push(a), }); - if (!opts?.skipPayload) runtime.setSessionEnvPayloadJson(PAYLOAD_JSON); - return { runtime, activities }; + if (!opts?.skipPayload) runtime.setSessionEnvPayloadJson(PAYLOAD_JSON, { passthroughCount: 2 }); + return { + runtime, activities, authFailures, served, + }; } test('serves the current payload with a valid token, without reporting egress activity', async () => { - const { runtime, activities } = await startWithEndpoint(); + const { + runtime, activities, served, authFailures, + } = await startWithEndpoint(); const res = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { host: 'varlock.internal', 'x-varlock-proxy-token': TOKEN, @@ -491,6 +501,9 @@ describe('varlock.internal session-env endpoint', () => { expect(JSON.parse(res.body)).toEqual(JSON.parse(PAYLOAD_JSON)); // control plane, not traffic: never counted as egress expect(activities).toEqual([]); + // owner visibility: served callback fires with the payload's meta, no auth failures + expect(served).toEqual([{ passthroughCount: 2 }]); + expect(authFailures).toEqual([]); await runtime.stop(); }); @@ -506,8 +519,8 @@ describe('varlock.internal session-env endpoint', () => { await runtime.stop(); }); - test('refuses a missing or wrong token with 403 and serves nothing', async () => { - const { runtime } = await startWithEndpoint(); + test('refuses a missing or wrong token with 403, serves nothing, and surfaces the attempt', async () => { + const { runtime, authFailures, served } = await startWithEndpoint(); const noToken = await requestViaProxy(runtime.env.HTTP_PROXY!, 'http://varlock.internal/session-env', { host: 'varlock.internal', }); @@ -518,6 +531,9 @@ describe('varlock.internal session-env endpoint', () => { 'x-varlock-proxy-token': 'wrong-token-same-length--', }); expect(wrongToken.statusCode).toBe(403); + // both attempts surfaced to the owner; nothing served + expect(authFailures).toEqual([true, true]); + expect(served).toEqual([]); await runtime.stop(); }); diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 908973409..3708e68c8 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -46,10 +46,10 @@ export type ProxyRuntimeContext = { reconfigure: (next: ProxyReconfigureInput) => void; /** * Set/replace the encoded session-env payload the `varlock.internal` endpoint - * serves (see `internalEndpointToken`). Called once after startup and again on + * serves (see `internalEndpoint`). Called once after startup and again on * every reload so attach fetches are always current. */ - setSessionEnvPayloadJson: (payloadJson: string) => void; + setSessionEnvPayloadJson: (payloadJson: string, meta?: SessionEnvPayloadMeta) => void; stop: () => Promise; }; @@ -83,10 +83,25 @@ export type StartLocalProxyRuntimeInput = { * attaching `proxy run` can adopt this session's env without resolving * anything itself. Requests to the internal host are answered by the proxy, * never forwarded upstream, and not reported as egress activity. The token is - * the session token from the 0600 record, so this gates at same-uid — the same - * trust level as the record itself, not a hard boundary. + * a dedicated endpoint credential stored in the 0600 session record (never + * displayed anywhere), so this gates at same-uid — the same trust level as + * the record itself, not a hard boundary. Loopback peers only, asserted even + * though the listener is loopback-bound, so a future non-loopback data-plane + * bind (sandbox bridging) can't silently expose the control plane. */ - internalEndpointToken?: string; + internalEndpoint?: { + token: string; + /** A request presented a missing/invalid token (or came from a non-loopback peer) — surface to the owner. */ + onAuthFailure?: () => void; + /** The session env payload was served; meta comes from the matching setSessionEnvPayloadJson call. */ + onServed?: (meta?: SessionEnvPayloadMeta) => void; + }; +}; + +/** Command-side metadata attached to the served payload (for owner-terminal visibility). */ +export type SessionEnvPayloadMeta = { + /** Count of sensitive items served with their REAL value (@proxy=passthrough). */ + passthroughCount?: number; }; type HostInfo = { host: string, port: number }; @@ -306,6 +321,11 @@ function tokenMatches(provided: unknown, expected: string): boolean { return timingSafeEqual(providedBuf, expectedBuf); } +function isLoopbackAddress(addr: string | undefined): boolean { + if (!addr) return false; + return addr === '::1' || addr === '::ffff:127.0.0.1' || addr.startsWith('127.'); +} + /** Transport-specific inputs for a proxied request, shared by the MITM-tunnel and * absolute-form (plain http) handlers so the policy/approval/injection/forwarding * logic lives in one place. */ @@ -590,7 +610,7 @@ export async function startLocalProxyRuntime({ onActivity, onResponse, approvalProvider, - internalEndpointToken, + internalEndpoint, }: StartLocalProxyRuntimeInput): Promise { // Mutable so `reconfigure` can hot-swap the enforced policy on a live proxy. // The request handlers below close over these bindings, so reassigning them @@ -600,6 +620,7 @@ export async function startLocalProxyRuntime({ let egressMode = initialEgressMode; // Set via setSessionEnvPayloadJson right after startup (and on each reload). let sessionEnvPayloadJson: string | undefined; + let sessionEnvPayloadMeta: SessionEnvPayloadMeta | undefined; // Only the public CA cert is written to disk (for child trust). Private keys // — the CA's and every per-host leaf's — stay in memory; see cert-authority.ts. const certsDir = await mkdtemp(path.join(os.tmpdir(), 'varlock-proxy-certs-')); @@ -620,7 +641,16 @@ export async function startLocalProxyRuntime({ res: http.ServerResponse, t: ProxiedRequestTransport, ) => { - if (!internalEndpointToken || !tokenMatches(req.headers[PROXY_TOKEN_HEADER], internalEndpointToken)) { + // Loopback peers only. Redundant today (the listener binds 127.0.0.1) but + // deliberate: a future non-loopback data-plane bind (sandbox bridging) must + // not silently expose the control plane to a network segment. + if (!isLoopbackAddress(req.socket.remoteAddress)) { + internalEndpoint?.onAuthFailure?.(); + respondBlocked(res, 403, 'varlock proxy: internal endpoint is loopback-only', t.tunnelTeardown); + return; + } + if (!internalEndpoint || !tokenMatches(req.headers[PROXY_TOKEN_HEADER], internalEndpoint.token)) { + internalEndpoint?.onAuthFailure?.(); respondBlocked(res, 403, 'varlock proxy: invalid or missing session token', t.tunnelTeardown); return; } @@ -635,6 +665,7 @@ export async function startLocalProxyRuntime({ try { res.writeHead(200, { 'content-type': 'application/json', connection: 'close' }); res.end(sessionEnvPayloadJson); + internalEndpoint.onServed?.(sessionEnvPayloadMeta); } catch { /* client went away */ } }; @@ -910,6 +941,18 @@ export async function startLocalProxyRuntime({ return; } + // A CONNECT tunnel to the internal host reaches handleInternalRequest via a + // local MITM pipe, where the original peer address is no longer visible — so + // the loopback-only assertion for the control plane must happen here. + // clientSocket is typed as Duplex but is a net.Socket at runtime + const connectPeer = (clientSocket as net.Socket).remoteAddress; + if (normalizeHost(hostInfo.host) === VARLOCK_INTERNAL_HOST && !isLoopbackAddress(connectPeer)) { + internalEndpoint?.onAuthFailure?.(); + clientSocket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + clientSocket.destroy(); + return; + } + const shouldRewrite = hostMatchesProxyRules(hostInfo.host, rules); const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; if (!shouldAllowEgress) { @@ -999,8 +1042,9 @@ export async function startLocalProxyRuntime({ CURL_CA_BUNDLE: combinedCaPath, GIT_SSL_CAINFO: combinedCaPath, }, - setSessionEnvPayloadJson: (payloadJson) => { + setSessionEnvPayloadJson: (payloadJson, meta) => { sessionEnvPayloadJson = payloadJson; + sessionEnvPayloadMeta = meta; }, reconfigure: (next) => { managedItems = next.managedItems; diff --git a/packages/varlock/src/proxy/session-registry.ts b/packages/varlock/src/proxy/session-registry.ts index 19f59499f..b923984e6 100644 --- a/packages/varlock/src/proxy/session-registry.ts +++ b/packages/varlock/src/proxy/session-registry.ts @@ -38,6 +38,14 @@ export type ProxySessionRecord = { */ attachedPids?: Array; egressMode: ProxyEgressMode; + /** + * Bearer credential for the session's `varlock.internal` control endpoint. + * Deliberately SEPARATE from `uuid`: the uuid is a display identifier (printed + * by `proxy status`, exported into the child env) while this token is never + * shown anywhere — it lives only in this 0600 record and the owner's memory, + * so a pasted status output or shared screen can't leak endpoint access. + */ + endpointToken?: string; schemaFingerprint?: string; /** * Sensitive item key → placeholder shown to the child. Covers `@proxy`-managed @@ -137,6 +145,7 @@ function parseSessionRecord(raw: string): ProxySessionRecord | undefined { ? { attachedPids: parsed.attachedPids.map((v) => Number(v)).filter((n) => Number.isFinite(n)) } : {}), egressMode: parsed.egressMode as ProxyEgressMode, + ...(parsed.endpointToken ? { endpointToken: String(parsed.endpointToken) } : {}), ...(parsed.schemaFingerprint ? { schemaFingerprint: String(parsed.schemaFingerprint) } : {}), ...(parsed.placeholderOverrides && typeof parsed.placeholderOverrides === 'object' ? { From e829e16a25879dc88135ac8ad58bb1dfed0535fa Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 21:14:04 -0700 Subject: [PATCH 61/64] fix(run): honor @encryptInjectedEnv when spawning with blob-only injection The setting previously applied only to the library auto-load path and build-time integrations; varlock run and proxy run injected a plaintext blob and merely forwarded a pre-existing key. A shared helper now encrypts the blob with an ephemeral key (reusing an ambient one when present) in blob-only mode, so resolved values never sit as plaintext in the child env. Leak resistance, not attacker resistance: the key rides alongside the ciphertext. --- .bumpy/encrypt-injected-blob-on-spawn.md | 7 ++++ .../docs/reference/root-decorators.mdx | 2 + .../varlock/src/cli/commands/run.command.ts | 15 ++++---- .../src/cli/helpers/injected-env-blob.ts | 37 +++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 .bumpy/encrypt-injected-blob-on-spawn.md create mode 100644 packages/varlock/src/cli/helpers/injected-env-blob.ts diff --git a/.bumpy/encrypt-injected-blob-on-spawn.md b/.bumpy/encrypt-injected-blob-on-spawn.md new file mode 100644 index 000000000..9af08b0c3 --- /dev/null +++ b/.bumpy/encrypt-injected-blob-on-spawn.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +`@encryptInjectedEnv` is now honored when `varlock run` / `varlock proxy run` inject the env blob. + +Previously the setting only applied to the library auto-load path and build-time integrations; the CLI spawn paths injected a plaintext `__VARLOCK_ENV` blob and merely forwarded a pre-existing key. In blob-only inject mode (`--inject blob`), the blob is now encrypted with an ephemeral key carried alongside it, so resolved values never sit in plaintext in the child's environment. This is leak resistance (crash reporters, env dumps, logs), not protection from an attacker who can read the full environment. diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index 10e13c970..fa531948f 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -402,6 +402,8 @@ Controls whether the injected env blob in server-side build output is encrypted. This is primarily relevant for serverless deployments (Vercel, Netlify, etc.) where varlock injects resolved env data into the build output. The blob only appears in server-side code and is generally safe, but encryption provides extra protection — particularly against secrets leaking via sourcemaps. +It also applies when `varlock run` / `varlock proxy run` spawn a child in blob-only inject mode (`--inject blob`): the injected blob is encrypted with an ephemeral key carried alongside it, so resolved values never sit as plaintext in the child's environment. Since the key rides next to the ciphertext, this protects against accidental leaks (crash reporters, env dumps, logs), not against an attacker who can read the full environment. + **Options:** - `false` (default): Blob is injected as plaintext JSON - `true`: Blob is encrypted, `_VARLOCK_ENV_KEY` required at build time and runtime diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index ce91a2ef9..5131fc6ac 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -8,6 +8,7 @@ import { checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors } from ' import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; import { REDACT_STDOUT_ARG, resolveStdoutRedaction, pipeRedactedStreams } from '../helpers/stdout-redaction'; +import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; export const commandSpec = define({ name: 'run', @@ -212,7 +213,13 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = ...process.env, ...(injectVars ? resolvedEnv : {}), __VARLOCK_RUN: '1', // flag for a child process to detect it is running via `varlock run` - ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(serializedGraph) } : {}), + // honors @encryptInjectedEnv in blob-only mode; reuses/forwards an ambient key + ...buildInjectedBlobEnv({ + serializedGraph, + injectVars, + injectBlob, + ambientEnvKey: process.env._VARLOCK_ENV_KEY, + }), }; // @internal items must not reach the application. The spread of process.env above can carry @@ -224,12 +231,6 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = } } - // when only injecting the blob, also inject the encryption key so the - // child process can decrypt it (if encrypted) - if (injectBlob && !injectVars && process.env._VARLOCK_ENV_KEY) { - fullInjectedEnv._VARLOCK_ENV_KEY = process.env._VARLOCK_ENV_KEY; - } - // Per-stream TTY auto-detect (interactive terminal -> raw inherit; piped/redirected -> // redact). Shared with `varlock proxy run` so the two commands can't diverge. const { redactStdout, redactStderr } = resolveStdoutRedaction({ diff --git a/packages/varlock/src/cli/helpers/injected-env-blob.ts b/packages/varlock/src/cli/helpers/injected-env-blob.ts new file mode 100644 index 000000000..b2d39caf3 --- /dev/null +++ b/packages/varlock/src/cli/helpers/injected-env-blob.ts @@ -0,0 +1,37 @@ +import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../../runtime/crypto'; + +/** + * Build the `__VARLOCK_ENV` (+ `_VARLOCK_ENV_KEY`) entries for a spawned child. + * Shared by `varlock run` and `varlock proxy run` so blob behavior can't diverge. + * + * Honors `@encryptInjectedEnv` in blob-only mode: the blob is encrypted with an + * ephemeral key that rides alongside it in the child env (the runtime decrypts + * transparently via isEncryptedBlob). Key + ciphertext co-located means this is + * LEAK resistance (crash reporters, env dumps, logs), not attacker resistance — + * same trust framing as the auto-load path. In `all` mode the values are already + * plaintext env vars, so encrypting the blob would protect nothing; it stays + * plaintext there. An ambient `_VARLOCK_ENV_KEY` is reused (nested runs keep one + * key) or forwarded even without encryption, matching prior behavior for + * build-time-encrypted contexts. + */ +export function buildInjectedBlobEnv(opts: { + serializedGraph: { settings?: { encryptInjectedEnv?: boolean } }; + injectVars: boolean; + injectBlob: boolean; + ambientEnvKey?: string; +}): { __VARLOCK_ENV?: string; _VARLOCK_ENV_KEY?: string } { + if (!opts.injectBlob) return {}; + + const json = JSON.stringify(opts.serializedGraph); + if (opts.injectVars) return { __VARLOCK_ENV: json }; + + if (opts.serializedGraph.settings?.encryptInjectedEnv) { + const key = opts.ambientEnvKey ?? generateEncryptionKeyHex(); + return { __VARLOCK_ENV: encryptEnvBlobSync(json, key), _VARLOCK_ENV_KEY: key }; + } + + return { + __VARLOCK_ENV: json, + ...(opts.ambientEnvKey ? { _VARLOCK_ENV_KEY: opts.ambientEnvKey } : {}), + }; +} From 24db4255e003e1ebc388fbc4a3ae16a7e52aaa9c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 23:30:06 -0700 Subject: [PATCH 62/64] docs(proxy): close doc-vs-code drift found by audit - document that approval-required rules fail closed (403) in one-shot proxy run, and how to get interactive approvals instead - document per-stream stdout/stderr redaction in the child-wiring section, with the override flags - add the approval option to the @proxy option table and arg types (it was only mentioned inside rules entries) - tighten the @encryptInjectedEnv spawn-path paragraph: conditional on the setting, and note ambient key reuse for nested runs --- packages/varlock-website/src/content/docs/guides/proxy.mdx | 6 ++++++ .../src/content/docs/reference/item-decorators.mdx | 3 ++- .../src/content/docs/reference/root-decorators.mdx | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/varlock-website/src/content/docs/guides/proxy.mdx b/packages/varlock-website/src/content/docs/guides/proxy.mdx index a9f9dc3f5..181f8a6e4 100644 --- a/packages/varlock-website/src/content/docs/guides/proxy.mdx +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -245,6 +245,10 @@ varlock proxy run -- Starts a proxy, runs the command through it, and tears down when the command exits. The single most common way to use the feature. If a `proxy start` daemon is already running for this directory, `proxy run` **attaches** to it (so its terminal owns the live request log and any prompts); otherwise it runs a self-contained proxy. Pass `--new` to force a fresh, separate proxy. +:::caution[Approval rules fail closed in one-shot mode] +A self-contained `proxy run` has no terminal to prompt in (the child owns its stdio), so requests matching an approval-required rule are denied with a 403. For interactive approvals, run `proxy start` in one terminal and let `proxy run` attach from another. +::: + ### Daemon + attach: `proxy start` ```bash @@ -289,6 +293,8 @@ Sessions are durable records: they persist after the proxy stops (visible with ` The proxy uses an **ephemeral, in-memory certificate authority** generated per run; only its public certificate is written to a temp file for the child to trust. The CA private key and all per-host certificates never leave memory. +**Stdout/stderr redaction** is decided per stream: a stream attached to an interactive terminal passes through raw (so TUIs like `claude` render correctly), while a piped or redirected stream is scrubbed of sensitive values, including the real values the proxy injects at the wire, in case an upstream response echoes one back. Override with `--redact-stdout` / `--no-redact-stdout`. + :::note[Clients that don't read these env vars] Tools that ignore the standard proxy/CA environment variables (many GUI apps, browsers, and some language runtimes with their own trust stores) won't be transparently proxied. The target here is CLI tools and agents, which generally do respect them. ::: diff --git a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx index 9f115b9ff..27f3c26e5 100644 --- a/packages/varlock-website/src/content/docs/reference/item-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/item-decorators.mdx @@ -256,7 +256,7 @@ OPENAI_API_KEY=sk-proj-... 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], [keys], [rules])`: +**Function form** `@proxy(domain=..., [path], [method], [block], [approval], [keys], [rules])`: | Option | Meaning | |---|---| @@ -264,6 +264,7 @@ Routes an item's secret through the [credential proxy](/guides/proxy/) so an unt | `path` | Restrict to matching URL paths (glob), e.g. `"/v1/**"`. | | `method` | Restrict to one or more HTTP methods, e.g. `[GET, POST]`. | | `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]`. | | `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` (not `domain`/`keys`) and injects nothing on its own. See the [Grouping rules guide](/guides/proxy/#grouping-rules-for-one-domain). | diff --git a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx index fa531948f..f70e06b72 100644 --- a/packages/varlock-website/src/content/docs/reference/root-decorators.mdx +++ b/packages/varlock-website/src/content/docs/reference/root-decorators.mdx @@ -402,7 +402,7 @@ Controls whether the injected env blob in server-side build output is encrypted. This is primarily relevant for serverless deployments (Vercel, Netlify, etc.) where varlock injects resolved env data into the build output. The blob only appears in server-side code and is generally safe, but encryption provides extra protection — particularly against secrets leaking via sourcemaps. -It also applies when `varlock run` / `varlock proxy run` spawn a child in blob-only inject mode (`--inject blob`): the injected blob is encrypted with an ephemeral key carried alongside it, so resolved values never sit as plaintext in the child's environment. Since the key rides next to the ciphertext, this protects against accidental leaks (crash reporters, env dumps, logs), not against an attacker who can read the full environment. +When enabled, it also applies when `varlock run` / `varlock proxy run` spawn a child in blob-only inject mode (`--inject blob`): the injected blob is encrypted with an ephemeral key carried alongside it (an ambient `_VARLOCK_ENV_KEY` is reused when present, so nested runs share one key), and resolved values never sit as plaintext in the child's environment. Since the key rides next to the ciphertext, this protects against accidental leaks (crash reporters, env dumps, logs), not against an attacker who can read the full environment. **Options:** - `false` (default): Blob is injected as plaintext JSON @@ -514,7 +514,7 @@ See the [credential proxy guide](/guides/proxy/) for the full workflow.
### `@proxy()` -**Arg types:** `(domain: string | string[], path?, method?: string | string[], block?, keys?: string[], rules?: object[], ...)` +**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=[...]`. From 605110a4f3c9434eb0ee09c9ec0979b6501d9902 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 23:45:48 -0700 Subject: [PATCH 63/64] chore: consolidate proxy changesets into one; drop stale phase-1 notes 18 per-feature proxy changesets collapse into a single preview entry for the `varlock proxy` command (the changelog doesn't need a line per sub-feature). Also removes docs/proxy-phase1-notes.md, which described the superseded deny-nested-commands guardrail approach (replaced by the resolution-view placeholder model). --- .bumpy/proxy-approval-granularity.md | 7 --- .bumpy/proxy-approval-scopes.md | 7 --- .bumpy/proxy-array-literals.md | 7 --- .bumpy/proxy-attach-adopts-session-env.md | 7 --- .bumpy/proxy-audit-log.md | 7 --- .bumpy/proxy-default-omit-sensitive.md | 7 --- .bumpy/proxy-dual-form-decorator.md | 7 --- .bumpy/proxy-hot-reload.md | 7 --- .bumpy/proxy-nested-rules.md | 7 --- .bumpy/proxy-phase1-guardrails.md | 21 -------- .bumpy/proxy-require-approval.md | 7 --- .bumpy/proxy-resolution-hardening.md | 9 ---- .bumpy/proxy-root-decorator-fix.md | 7 --- .bumpy/proxy-rules-command.md | 10 ---- .bumpy/proxy-run-attach.md | 7 --- .bumpy/proxy-schema-fingerprint-full.md | 7 --- .bumpy/proxy-session-record.md | 7 --- .bumpy/proxy-start-request-log.md | 16 ------ .bumpy/proxy.md | 7 +++ docs/proxy-phase1-notes.md | 60 ----------------------- 20 files changed, 7 insertions(+), 214 deletions(-) delete mode 100644 .bumpy/proxy-approval-granularity.md delete mode 100644 .bumpy/proxy-approval-scopes.md delete mode 100644 .bumpy/proxy-array-literals.md delete mode 100644 .bumpy/proxy-attach-adopts-session-env.md delete mode 100644 .bumpy/proxy-audit-log.md delete mode 100644 .bumpy/proxy-default-omit-sensitive.md delete mode 100644 .bumpy/proxy-dual-form-decorator.md delete mode 100644 .bumpy/proxy-hot-reload.md delete mode 100644 .bumpy/proxy-nested-rules.md delete mode 100644 .bumpy/proxy-phase1-guardrails.md delete mode 100644 .bumpy/proxy-require-approval.md delete mode 100644 .bumpy/proxy-resolution-hardening.md delete mode 100644 .bumpy/proxy-root-decorator-fix.md delete mode 100644 .bumpy/proxy-rules-command.md delete mode 100644 .bumpy/proxy-run-attach.md delete mode 100644 .bumpy/proxy-schema-fingerprint-full.md delete mode 100644 .bumpy/proxy-session-record.md delete mode 100644 .bumpy/proxy-start-request-log.md create mode 100644 .bumpy/proxy.md delete mode 100644 docs/proxy-phase1-notes.md diff --git a/.bumpy/proxy-approval-granularity.md b/.bumpy/proxy-approval-granularity.md deleted file mode 100644 index 49616a75c..000000000 --- a/.bumpy/proxy-approval-granularity.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy approvals: per-rule granularity and a max-duration cap. - -`@proxy(approval=true)` rules accept an options object for finer control: `@proxy(approval={each="request", maxDuration="15m"})`. `each` is how finely to ask — `host`, `endpoint` (method+path), or `request` (method+path+body) — and `maxDuration` is the ceiling on how long a "yes" is remembered (e.g. `"15m"`, or `0` for always-ask). The object form implies approval is required; `enabled=false` turns the rule back into a plain allow. A standing grant is keyed by the matched rule plus its granularity, so one broad rule can yield per-endpoint or per-exact-request approvals without writing many rules. The cap is enforced proxy-side: the approver's chosen lifetime is clamped to `maxDuration`, so no approver can exceed what the schema allows (always-ask is provably one-tap-per-request). diff --git a/.bumpy/proxy-approval-scopes.md b/.bumpy/proxy-approval-scopes.md deleted file mode 100644 index d6e8cc2b8..000000000 --- a/.bumpy/proxy-approval-scopes.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy approvals: approve once, for the session, or for a time window. - -The `require-approval` terminal prompt (`varlock proxy start`) now offers scopes — `[y] once`, `[s] this session`, `[m] 15 min` — instead of a plain yes/no. A session- or duration-scoped approval is remembered as a standing grant (stored per session, no secret values) so later requests matching the same `@proxy(approve=true)` rule are auto-approved without re-prompting. Grants are decoupled from the approver behind a store, so the future phone / native-app approver reuses the same mechanism. diff --git a/.bumpy/proxy-array-literals.md b/.bumpy/proxy-array-literals.md deleted file mode 100644 index 48400bc65..000000000 --- a/.bumpy/proxy-array-literals.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -`@proxy` uses array literals for lists. - -`domain` and `method` now accept an array literal for matching multiple values (`domain=[api.a.com, api.b.com]`, `method=[GET, POST]`), and a detached rule attaches extra items with `keys=[ITEM_A, ITEM_B]` instead of positional `$REF`s. A single value still works (`domain="api.x.com"`). diff --git a/.bumpy/proxy-attach-adopts-session-env.md b/.bumpy/proxy-attach-adopts-session-env.md deleted file mode 100644 index 103e0239e..000000000 --- a/.bumpy/proxy-attach-adopts-session-env.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy: attaching `proxy run` now adopts the running session's env. - -When `varlock proxy run` attaches to a running `proxy start` daemon for the directory, it now fetches the child-view env (placeholders, non-secret values, omitted keys) directly from the daemon instead of re-resolving the schema itself. Attaching no longer triggers a second unlock prompt, and the session's own overrides and env selection apply to the child, so attaching from a shell with different env vars can no longer produce a mismatched env. diff --git a/.bumpy/proxy-audit-log.md b/.bumpy/proxy-audit-log.md deleted file mode 100644 index 15a43b3f4..000000000 --- a/.bumpy/proxy-audit-log.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Add an append-only audit log for proxy sessions (Invariant #7). - -Every proxied request now records one JSON line — timestamp, host, method, path, a request fingerprint hash, the matched rule, the decision (allow / deny / blocked-egress / blocked-cleartext), and which managed items were injected (by key name). No secret values, query strings, or request bodies are ever written. Logs are stored per session under `~/.config/varlock/proxy/audit/.jsonl` and persist after the session ends. View them with `varlock proxy audit [--session ] [--format text|json]`. diff --git a/.bumpy/proxy-default-omit-sensitive.md b/.bumpy/proxy-default-omit-sensitive.md deleted file mode 100644 index d5c3ad1ad..000000000 --- a/.bumpy/proxy-default-omit-sensitive.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy: show the agent a placeholder for every sensitive item by default. - -Inside `varlock proxy run|start`, any `@sensitive` item the agent sees is replaced with a placeholder — the `@proxy(domain=...)`-routed ones (whose real value is injected at the wire) plus every other sensitive item (which simply isn't injected anywhere). The real value never reaches the child. Use `@proxy=passthrough` to inject the real value (escape hatch) or `@proxy=omit` to withhold an item entirely. Varlock's own `_VARLOCK_*` reserved keys are internal infrastructure and are excluded from this policy. diff --git a/.bumpy/proxy-dual-form-decorator.md b/.bumpy/proxy-dual-form-decorator.md deleted file mode 100644 index 664179988..000000000 --- a/.bumpy/proxy-dual-form-decorator.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy: unify `@proxyPassthrough` into a dual-form `@proxy` decorator. - -`@proxy` can now be used as a function — `@proxy(domain=...)` to route a value through the proxy (the agent sees a placeholder) — or as a value: `@proxy=passthrough` injects the real value into the proxied child, and `@proxy=omit` explicitly withholds it (no "no policy set" warning). The two forms are mutually exclusive on a single item. This replaces the separate `@proxyPassthrough` decorator. diff --git a/.bumpy/proxy-hot-reload.md b/.bumpy/proxy-hot-reload.md deleted file mode 100644 index d201b92b8..000000000 --- a/.bumpy/proxy-hot-reload.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -`varlock proxy reload` hot-reloads a running proxy without a restart. - -After editing your schema, run `varlock proxy reload` from a trusted terminal to re-resolve it in the proxy's trusted context and swap the live policy (rules, injected secrets, egress mode) without restarting or dropping your agent's connection. A reload requested from inside the proxied agent is refused and logged, so an agent can't self-approve its own schema edit. Set the posture with `@proxyConfig={reload="off"|"manual"|"auto"}` (default `auto`, which enables it only for an interactive `proxy start`) or override per run with `--allow-reload` / `--no-allow-reload`. On a shared uid this is a bar-raiser, not a hard boundary, so pair it with a sandbox. diff --git a/.bumpy/proxy-nested-rules.md b/.bumpy/proxy-nested-rules.md deleted file mode 100644 index 386c7c7f4..000000000 --- a/.bumpy/proxy-nested-rules.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -`@proxy` supports a `rules=[{...}]` array to group several path/method policies under one domain. - -Instead of repeating `domain` on every rule, write it once and list the refinements: `@proxy(domain="api.stripe.com", rules=[{path="/v1/refunds/**", block=true}, {path="/v1/payouts/**", block=true}])`. The parent `@proxy(...)` still controls injection; each entry is a policy-only refinement (it may set `path`/`method`/`block`/`approval`, inherits the domain, and injects nothing). diff --git a/.bumpy/proxy-phase1-guardrails.md b/.bumpy/proxy-phase1-guardrails.md deleted file mode 100644 index 53cc59a59..000000000 --- a/.bumpy/proxy-phase1-guardrails.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -varlock: patch ---- - -Add proxy guardrails and strict egress enforcement for run --proxy. - -Verified-upstream-identity binding (Invariant #1): secrets are injected only onto upstream TLS connections that validate against the public PKI AND whose cert identity matches the rule-targeted host. A DNS-poisoned / rebound host can't present a valid cert for the target, so it causes a failed connection, never a leaked secret. Cleartext guard (Invariant #2): refuse to inject a secret into a non-TLS (http://) connection; fail closed. Upstream-error path now fails closed (tears down the client connection) rather than half-delivering. - -Response scrubbing (Invariant #6): real values reflected in responses are replaced back to placeholders — now including streamed (SSE/chunked) text responses, scrubbed chunk-by-chunk without buffering (so a secret echoed in a stream never reaches the child while streaming still works). Bounded responses gain a post-scrub fail-safe: if a real value somehow survives redaction, the response is withheld rather than leaked. Compressed/binary bodies still pass through unscanned (documented residual). - -Per-call policy / static authorization: requests are evaluated as facts (host + method + path) against the `@proxy` rules. A matching `block=true` rule denies the request (fail closed — never reaches upstream), and credential injection is now scoped by path/method too (`getRequestScopedManagedItems`), so a secret can be limited to specific endpoints/methods, not just a host. Modeled as `facts → verdict` (allow|deny|require-approval) with a generic fact bag so domain plugins / non-HTTP protocols slot onto the same seam later. Glob path matching (`*` within a segment, `**` across). - -Local-MVP hardening: per-item domain scoping (an item's secret is injected only on hosts its own rule matches), response redaction of headers + uncompressed bodies (compressed bodies pass through — most APIs don't reflect credentials, and forcing identity on every request wasn't worth the cost), schema-fingerprint enforcement on nested commands (closes the @sensitive-downgrade re-load), and correctness fixes (byte-accurate content-length, strict-mode 403 length). - -In-memory ephemeral CA: cert generation moved from the `openssl` subprocess to `@peculiar/x509` over WebCrypto (EC P-256). CA and per-host leaf private keys are now generated and held in memory — only the public CA cert is written to disk for child trust. Removes the openssl dependency (portability for the compiled binary) and closes the on-disk private-key exposure. Leaf certs for IP-literal ruled domains now use an IP SAN (clients verify IPs against iPAddress, not dNSName), so IP-based `@proxy` domains work. Validated end-to-end through the compiled binary (real HTTPS MITM) and via an in-process TLS integration test. - -Process-ancestry context detection: nested-command guards and placeholder overrides now detect the proxy session by matching a running session's owner/child PID against the current process's parent chain, not just the inherited `__VARLOCK_PROXY_CHILD` marker. Closes the `env -u __VARLOCK_PROXY_CHILD varlock load` bypass that previously recovered real values; a child would now have to daemonize/reparent to escape the process tree. Marker-absent-but-ancestry-positive is logged as a likely bypass probe. - -Streaming responses: the proxy no longer buffers `text/event-stream` (and other unknown-length) responses for body redaction — they stream through incrementally, so LLM/agent token-by-token responses work. Body redaction now applies only to bounded, small (<2MB content-length) text responses; header redaction still applies to all responses. - -Placeholder generation: the placeholder is functionally load-bearing (a bad one fails an SDK's key-format check at client construction), so generation now favors known-format sources. `@example` derivation was removed (a docs field shouldn't double as a validation-critical placeholder); priority is explicit `@placeholder` → data-type `generatePlaceholder()` → `@type` constraints → generic fallback. Items that land on the generic fallback are flagged and a warning is printed at proxy startup. diff --git a/.bumpy/proxy-require-approval.md b/.bumpy/proxy-require-approval.md deleted file mode 100644 index 80db079f4..000000000 --- a/.bumpy/proxy-require-approval.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Add a `require-approval` verdict to the proxy (Invariant #8). - -Mark a rule `@proxy(domain="...", approve=true)` and matching requests are held for an out-of-band, request-bound approval before being forwarded. The approval commits to the exact request — method + verified host + path + body hash + nonce + expiry — so a future signed phone-approval relay drops in unchanged. Precedence is block > require-approval > allow (most restrictive wins). The MVP approver is a terminal prompt under `varlock proxy start` (where the proxy owns the terminal; under `varlock proxy run` the child owns stdio, so approval-required requests fail closed). Everything fails closed — denied, timed-out, or unanswerable approvals never reach upstream — and each outcome is recorded in the audit log as `approval-granted` / `approval-denied`. diff --git a/.bumpy/proxy-resolution-hardening.md b/.bumpy/proxy-resolution-hardening.md deleted file mode 100644 index a5ae023df..000000000 --- a/.bumpy/proxy-resolution-hardening.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -varlock: patch ---- - -Proxy: harden secret isolation and fail-closed config validation. - -- A proxied agent can no longer recover a secret by re-resolving the schema (`varlock load`/`printenv`/`run`): inside a proxy session every sensitive item resolves to its placeholder, and `@proxy=omit` items resolve to unset — never the real value. Detection now uses the env marker, the session token, and process ancestry together, so clearing `__VARLOCK_PROXY_CHILD` doesn't bypass the schema-fingerprint guard. -- `@proxy(...)` now rejects unknown options (e.g. a typo like `aproval=true`) and wrong-typed `block`/`approval`/`path` at load time instead of silently producing a permissive rule. -- Type-aware placeholders: `@type=url`/`email`/`uuid`/`md5` get a valid, unique placeholder so SDK format checks pass; all placeholders are unique per item. diff --git a/.bumpy/proxy-root-decorator-fix.md b/.bumpy/proxy-root-decorator-fix.md deleted file mode 100644 index 3b5c431e9..000000000 --- a/.bumpy/proxy-root-decorator-fix.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Fix: allow `@proxy(...)` in the `.env.schema` header for "detached" proxy rules. - -`@proxy` is both an item decorator (attached rules) and a root decorator (detached rules), but the header placement check rejected it as a misplaced item decorator, so detached rules — including header-level `block`/`approve` rules — couldn't be authored. A decorator registered as both is now accepted in the header. diff --git a/.bumpy/proxy-rules-command.md b/.bumpy/proxy-rules-command.md deleted file mode 100644 index 55c34131c..000000000 --- a/.bumpy/proxy-rules-command.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -varlock: patch ---- - -Add `varlock proxy rules` to summarize the effective `@proxy` configuration. - -Prints the routing rules (host / path / method, block / approval) and each -secret's mode — proxied (placeholder, injected), placeholder (sensitive, no -rule), passthrough (real value), or omit — without starting a proxy. Handy for -verifying a schema and seeing what an agent could and couldn't reach. diff --git a/.bumpy/proxy-run-attach.md b/.bumpy/proxy-run-attach.md deleted file mode 100644 index 0e2952182..000000000 --- a/.bumpy/proxy-run-attach.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -`varlock proxy run` attaches to a running proxy when possible. - -Instead of always starting a fresh (auto-deny) proxy, `proxy run` now attaches to a `proxy start` daemon for the current directory — so the daemon's terminal handles approval prompts while you run the agent in another. It picks the single running session whose directory contains yours (or use `--session `), validates the schema fingerprint (and tells you to restart the proxy on drift, rather than silently routing through a stale one), and injects the session's proxy env + placeholders. Pass `--new` to force a separate fresh proxy. This is the missing piece that makes interactive approval usable from a `run`-style agent command. diff --git a/.bumpy/proxy-schema-fingerprint-full.md b/.bumpy/proxy-schema-fingerprint-full.md deleted file mode 100644 index 28326a4b2..000000000 --- a/.bumpy/proxy-schema-fingerprint-full.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy: the schema fingerprint now covers the full schema definition. - -The fingerprint that guards an active proxy session against schema drift (and that `varlock proxy reload` re-applies) now hashes each config item's value definitions (pre-resolution: no secrets, no I/O) plus every decorator, and all root decorators, instead of only key/sensitivity/required/type. Cosmetic decorators (`@example`, `@docs`, `@docsUrl`, `@icon`, `@deprecated`) are marked `inert` and excluded, and decorator order, named-arg order, comments, and whitespace don't affect it. This closes gaps where a behavioral change left the fingerprint unchanged, e.g. flipping a secret from `@proxy(domain=…)` to `@proxy=passthrough`, changing a `@proxy` domain, or the egress mode. diff --git a/.bumpy/proxy-session-record.md b/.bumpy/proxy-session-record.md deleted file mode 100644 index ff61254cf..000000000 --- a/.bumpy/proxy-session-record.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -varlock: patch ---- - -Proxy sessions are kept as a durable record instead of deleted on stop. - -Each session now lives in its own directory (`proxy/sessions//`) holding its `session.json`, `audit.jsonl`, and `grants.jsonl` together. Stopping a session marks it ended rather than deleting it, so its audit log and approval grants survive for later inspection. `proxy status` shows active sessions by default; pass `--all` to include ended ones. diff --git a/.bumpy/proxy-start-request-log.md b/.bumpy/proxy-start-request-log.md deleted file mode 100644 index e0f8fbbf6..000000000 --- a/.bumpy/proxy-start-request-log.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -varlock: patch ---- - -`varlock proxy start` now prints a live request log to its terminal. - -Each proxied request shows a color-coded one-line decision with the keys injected -on the way out, and each forwarded response shows its status and any keys scrubbed -back to placeholders on the way in (`→` green / `✗` red request, `←` cyan response, -status colored by class, injected/scrubbed key names highlighted): - -``` -→ GET httpbin.org/get inject: API_TOKEN -← GET httpbin.org/get 200 scrubbed: API_TOKEN -✗ GET example.com/ blocked-egress -``` diff --git a/.bumpy/proxy.md b/.bumpy/proxy.md new file mode 100644 index 000000000..b304fe214 --- /dev/null +++ b/.bumpy/proxy.md @@ -0,0 +1,7 @@ +--- +varlock: patch +--- + +Add `varlock proxy`: a local credential proxy for AI agents (preview). + +Run an agent (or any untrusted tool) through a local MITM proxy so it only ever sees placeholder secrets: real values are injected at the wire (bound to a verified upstream TLS identity), responses are scrubbed back to placeholders, and every request is policy-checked and audited. Mark a secret with `@proxy(domain="api.example.com")`; sensitive items are shown to the child as placeholders by default, with `@proxy=passthrough` / `@proxy=omit` escape hatches. Route with host/path/method rules (`block`, `approval`), set egress with `@proxyConfig={egress="strict"}`, and hot-reload live policy with `varlock proxy reload`. Sessions are durable and auditable (`varlock proxy status` / `rules` / `audit`); `proxy start` runs a daemon with a live request log that other `proxy run` invocations attach to. Preview: same-uid, not a sandbox. See the [proxy guide](https://varlock.dev/guides/proxy/). diff --git a/docs/proxy-phase1-notes.md b/docs/proxy-phase1-notes.md deleted file mode 100644 index d4024e4e4..000000000 --- a/docs/proxy-phase1-notes.md +++ /dev/null @@ -1,60 +0,0 @@ -# Proxy Mode Phase 1 Notes - -## What We Learned - -- `--proxy` placeholder injection is useful, but not sufficient on its own. -- In proxied child processes, nested `varlock` invocations can potentially recover raw secrets unless explicitly guarded. -- For the current risk model (preventing accidental/bad agent behavior, not defeating a determined local attacker), explicit command guardrails are a practical first step. -- For coding agents, reading and editing `.env.schema` is useful and should stay possible. - -## Phase 1 Goal - -Make proxied runs safer by default by blocking obvious raw-secret recovery paths while preserving useful debugging workflows. - -## Phase 1 Safety Model - -- Treat proxied child process as untrusted for secret retrieval. -- Allow safe inspection commands. -- Deny commands/formats that can reveal plaintext secrets. -- Log and clearly explain blocked actions. - -## Immediate Guardrails - -- Add a proxied-child marker env var in `varlock run --proxy`. -- In proxied context, deny nested: - - `varlock run` - - `varlock printenv` - - `varlock reveal` -- In proxied context, restrict `varlock load` output: - - Allow default `pretty` output. - - Allow `json` / `json-full` only with `--agent`. - - Deny `env` / `shell` formats. -- In proxied context, verify schema fingerprint for `varlock load`: - - Fingerprint is captured at proxy start from approved schema shape. - - If schema changes during run, proxied `load` is blocked until proxy restart/approval. - -## Known Limitation (Important) - -If the agent edits `.env.schema` to downgrade an item from sensitive to non-sensitive, a schema fingerprint mismatch now blocks proxied `load`; full approval workflows are still needed so changes can be intentionally reviewed and activated. - -## Planned Follow-up - -Introduce an explicit approval gate for schema changes before they affect active proxy behavior: - -1. Agent can edit schema -> state becomes `pending`. -2. Proxy keeps using last approved policy. -3. Native macOS binary presents review/approval UI. -4. Only approved changes are activated (reload policy snapshot). - -This avoids silent sensitivity downgrades taking effect inside proxied runs. - -## macOS-First Approach - -- Trusted approver: Swift/native binary (menu bar + modal). -- Untrusted actor: proxied child process. -- CLI blocks and waits for binary approval for sensitive operations in later phase extensions. - -## Long-Term Direction - -- Phase 2: local isolated broker mode (secret values out of agent process memory). -- Phase 3: hosted/BYOC brokered control plane (stronger isolation + policy + audit), suitable for paid offering. From 5d3400cffc317bad9901591fcdbb301dc2325751 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 3 Jul 2026 23:57:24 -0700 Subject: [PATCH 64/64] refactor(proxy): dedup and dead-code cleanup from simplification pass - share normalizeHost/domainMatches from policy.ts instead of copies in runtime-proxy.ts - inline the buildPathnameAndQuery one-liner wrapper (its sibling call site already used replacePlaceholdersWithReal directly) - reload validation uses a lightweight loadResolvedProxyGraph instead of building a full policy it throws away - share --inject mode parsing (resolveInjectMode) between run and proxy run, removing duplicated validation --- .../varlock/src/cli/commands/proxy.command.ts | 38 +++++++++---------- .../varlock/src/cli/commands/run.command.ts | 9 +---- .../varlock/src/cli/helpers/inject-mode.ts | 22 +++++++++++ packages/varlock/src/proxy/policy.ts | 4 +- packages/varlock/src/proxy/runtime-proxy.ts | 22 +---------- 5 files changed, 46 insertions(+), 49 deletions(-) create mode 100644 packages/varlock/src/cli/helpers/inject-mode.ts diff --git a/packages/varlock/src/cli/commands/proxy.command.ts b/packages/varlock/src/cli/commands/proxy.command.ts index e4ee36cbd..b0757adf9 100644 --- a/packages/varlock/src/cli/commands/proxy.command.ts +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -57,6 +57,7 @@ import { } from '../../proxy/reload-channel'; import { isInProxyContext } from '../helpers/proxy-context-guard'; import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; +import { resolveInjectMode } from '../helpers/inject-mode'; import { buildSessionEnvPayload, decodeSessionEnvPayload, @@ -328,7 +329,13 @@ function getRunCommandArgs(): Array { return rest; } -async function prepareProxyPolicy(entryFilePaths?: Array): Promise { +/** + * Load + resolve + validate the schema in the proxy command's own (trusted) + * context, throwing on any schema/config error. This is the part `proxy reload` + * needs to fail loudly on a broken edit; `prepareProxyPolicy` builds the full + * policy on top of it. + */ +async function loadResolvedProxyGraph(entryFilePaths?: Array) { const envGraph = await loadVarlockEnvGraph({ entryFilePaths, // The proxy command manages the session fingerprint itself; don't subject @@ -341,6 +348,11 @@ async function prepareProxyPolicy(entryFilePaths?: Array): Promise): Promise { + const envGraph = await loadResolvedProxyGraph(entryFilePaths); const resolvedEnv = envGraph.getResolvedEnvObject(); const serializedGraph = envGraph.getSerializedGraph(); @@ -790,22 +802,6 @@ async function createRuntimeAndSession(opts: { }; } -function applyInjectModeFromFlags(ctx: any): { - injectVars: boolean; - injectBlob: boolean; -} { - const injectMode = ctx.values.inject ?? 'all'; - const validModes = ['all', 'vars', 'blob']; - if (!validModes.includes(injectMode)) { - throw new CliExitError(`Invalid --inject mode: "${injectMode}". Must be one of: ${validModes.join(', ')}`); - } - - return { - injectVars: injectMode === 'all' || injectMode === 'vars', - injectBlob: injectMode === 'all' || injectMode === 'blob', - }; -} - function formatSessionStatus(session: ProxySessionRecord): string { const child = session.childPid ? ` child=${session.childPid}` : ''; const stats = session.stats ?? EMPTY_PROXY_SESSION_STATS; @@ -1354,7 +1350,7 @@ async function runAction(ctx: any) { }; } - const { injectVars, injectBlob } = applyInjectModeFromFlags(ctx); + const { injectVars, injectBlob } = resolveInjectMode(ctx.values.inject); const commandProcess = spawnProxiedChild({ payload, session, @@ -1623,9 +1619,11 @@ async function reloadAction(ctx: any) { } // Validate the new schema here (in this context) so an obviously broken edit - // fails loudly at the call site, not only in the owner's logs. + // fails loudly at the call site, not only in the owner's logs. Only the + // load/resolve/validate is needed; the owner recomputes the full policy when + // it applies the reload. const reloadPaths = ctx.values.path ?? session.entryPaths; - await prepareProxyPolicy(reloadPaths).catch((error) => { + await loadResolvedProxyGraph(reloadPaths).catch((error) => { throw new CliExitError(`Schema does not resolve: ${(error as Error).message}`); }); diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 5131fc6ac..4f4d44f5a 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -9,6 +9,7 @@ import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; import { REDACT_STDOUT_ARG, resolveStdoutRedaction, pipeRedactedStreams } from '../helpers/stdout-redaction'; import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; +import { resolveInjectMode } from '../helpers/inject-mode'; export const commandSpec = define({ name: 'run', @@ -201,13 +202,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = console.warn('[varlock] ⚠️ --no-inject-graph is deprecated, use --inject vars instead'); injectDefault = 'vars'; } - const injectMode = ctx.values.inject ?? injectDefault; - const validModes = ['all', 'vars', 'blob']; - if (!validModes.includes(injectMode)) { - throw new CliExitError(`Invalid --inject mode: "${injectMode}". Must be one of: ${validModes.join(', ')}`); - } - const injectVars = injectMode === 'all' || injectMode === 'vars'; - const injectBlob = injectMode === 'all' || injectMode === 'blob'; + const { injectVars, injectBlob } = resolveInjectMode(ctx.values.inject, injectDefault as 'all' | 'vars'); const fullInjectedEnv: NodeJS.ProcessEnv = { ...process.env, diff --git a/packages/varlock/src/cli/helpers/inject-mode.ts b/packages/varlock/src/cli/helpers/inject-mode.ts new file mode 100644 index 000000000..dd168c9bb --- /dev/null +++ b/packages/varlock/src/cli/helpers/inject-mode.ts @@ -0,0 +1,22 @@ +import { CliExitError } from './exit-error'; + +const VALID_INJECT_MODES = ['all', 'vars', 'blob'] as const; + +/** + * Resolve the `--inject` flag into the two injection booleans, validating the + * value. Shared by `varlock run` and `varlock proxy run` so the accepted modes + * and their meaning can't drift between the two commands. + */ +export function resolveInjectMode(mode: string | undefined, fallback: 'all' | 'vars' | 'blob' = 'all'): { + injectVars: boolean; + injectBlob: boolean; +} { + const injectMode = mode ?? fallback; + if (!(VALID_INJECT_MODES as ReadonlyArray).includes(injectMode)) { + throw new CliExitError(`Invalid --inject mode: "${injectMode}". Must be one of: ${VALID_INJECT_MODES.join(', ')}`); + } + return { + injectVars: injectMode === 'all' || injectMode === 'vars', + injectBlob: injectMode === 'all' || injectMode === 'blob', + }; +} diff --git a/packages/varlock/src/proxy/policy.ts b/packages/varlock/src/proxy/policy.ts index c0de0f551..981b13f62 100644 --- a/packages/varlock/src/proxy/policy.ts +++ b/packages/varlock/src/proxy/policy.ts @@ -20,11 +20,11 @@ export type PolicyDecision = { reason?: string; }; -function normalizeHost(host: string): string { +export function normalizeHost(host: string): string { return host.toLowerCase().trim(); } -function domainMatches(domainPattern: string, host: string): boolean { +export function domainMatches(domainPattern: string, host: string): boolean { const pattern = normalizeHost(domainPattern); const normalizedHost = normalizeHost(host); if (pattern.startsWith('*.')) { diff --git a/packages/varlock/src/proxy/runtime-proxy.ts b/packages/varlock/src/proxy/runtime-proxy.ts index 3708e68c8..523b57c3f 100644 --- a/packages/varlock/src/proxy/runtime-proxy.ts +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -18,7 +18,7 @@ import { import type { ProxyActivity } from './audit'; import { createEphemeralCa, createHostCert } from './cert-authority'; import { - describeRule, evaluateProxyPolicy, getRequestScopedManagedItems, type RequestFacts, + describeRule, domainMatches, evaluateProxyPolicy, getRequestScopedManagedItems, normalizeHost, type RequestFacts, } from './policy'; import { PROXY_TOKEN_HEADER, SESSION_ENV_ENDPOINT_PATH, VARLOCK_INTERNAL_HOST, @@ -125,20 +125,6 @@ function parseHostPort(value: string): HostInfo | null { } } -function normalizeHost(host: string): string { - return host.toLowerCase().trim(); -} - -function domainMatches(domainPattern: string, host: string): boolean { - const pattern = normalizeHost(domainPattern); - const normalizedHost = normalizeHost(host); - if (pattern.startsWith('*.')) { - const suffix = pattern.slice(2); - return normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`); - } - return normalizedHost === pattern; -} - function hostMatchesProxyRules(host: string, rules: Array): boolean { return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); } @@ -595,10 +581,6 @@ async function readBody(req: http.IncomingMessage): Promise { return Buffer.concat(chunks); } -function buildPathnameAndQuery(input: string, managedItems: Array): string { - return replacePlaceholdersWithReal(input, managedItems); -} - /** * Local MITM proxy runtime for `varlock proxy run`. * Rewrites placeholder values to real values for requests matching @proxy domains. @@ -765,7 +747,7 @@ export async function startLocalProxyRuntime({ ? Buffer.from(replacePlaceholdersWithReal(body.toString('utf8'), hostItems), 'utf8') : body; const rewrittenPath = shouldRewrite - ? buildPathnameAndQuery(t.requestTarget, hostItems) + ? replacePlaceholdersWithReal(t.requestTarget, hostItems) : t.requestTarget; const upstreamHeaders = transformHeaders(