Skip to content

Local credential proxy for AI agents (proxy MVP)#780

Open
theoephraim wants to merge 31 commits into
mainfrom
feat/proxy-mvp
Open

Local credential proxy for AI agents (proxy MVP)#780
theoephraim wants to merge 31 commits into
mainfrom
feat/proxy-mvp

Conversation

@theoephraim

@theoephraim theoephraim commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

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 cryptographically verified upstream identity, responses are scrubbed back to placeholders, and every request is policy-checked, optionally gated on approval, and audited.

# @enableProxy()
# ---
# @proxy(domain="api.openai.com")
OPENAI_API_KEY=sk-...
varlock proxy run -- claude   # the agent sees a placeholder; the proxy swaps it in on the wire

What it does

  • varlock proxy command + sessions — its own command with a durable session registry. proxy run attaches to a running proxy start daemon for the current directory (so its terminal handles approvals), validating the schema fingerprint and failing loudly on drift; --session <id> targets one, --new forces a fresh proxy. Subcommands: run, start, rules, env, status, audit, refresh, stop.
  • Placeholders by default — every @sensitive item the child sees is a placeholder: @proxy(domain=...)-managed items (real value injected at the wire) plus every other sensitive item (just hidden). Because resolution is proxy-aware, the agent can't recover a secret by re-running varlock load/printenv/run — it gets the same placeholder back. Opt out per item with @proxy=passthrough (inject the real value) or @proxy=omit (withhold entirely). _VARLOCK_* reserved keys are never proxied. Placeholders are type-aware (url/email/uuid/md5 get valid, unique forms) and always unique per item so wire-scrubbing can't confuse two secrets.
  • Security invariants — inject only onto connections whose upstream TLS identity is verified against public CAs (defeats DNS/host tampering); refuse injection over cleartext; scrub real values back to placeholders in responses (best-effort — small uncompressed text bodies and SSE; see Limitations).
  • Per-call policy — host / path (glob) / method matching with block (deny) and request-scoped injection. Precedence block > require-approval > allow.
  • Approval (experimental, Invariant varlock init command #8)@proxy(approval={each=..., maxDuration=...}) (or approval=true) holds a request for an out-of-band, request-bound decision (method + verified host + path + body-hash + nonce + expiry) before forwarding, so a future signed phone-approval relay drops in unchanged. each (host/endpoint/request) sets what one grant covers; maxDuration caps how long a "yes" is remembered, clamped proxy-side. MVP approver is a TTY prompt under proxy start; everything fails closed.
  • Unified proxy-mode detection — env marker → session token → process ancestry, behind one memoized resolver used by the context guard, the schema-fingerprint guard, and resolution, so clearing __VARLOCK_PROXY_CHILD can't bypass the guards.
  • Fail-closed schema validation@proxy(...) rejects unknown options and wrong-typed block/approval/path at load and resolve time, instead of silently producing a permissive rule.
  • Append-only audit log (Invariant basic vscode plugin to provide highlighting #7) — one no-secrets JSON line per request; view with varlock proxy audit.
  • Live request log in proxy start — a color-coded line per request (decision + injected keys) and response (status + scrubbed keys).
  • varlock proxy rules — summarize the effective config (rules + per-secret mode) without starting a proxy.
  • Schema-fingerprint + context guards — block proxied loads if the schema changes after the proxy starts (covers value sources + all non-inert decorators), and block nested secret-recovery commands in the proxied child.
  • Ephemeral in-memory MITM CA (@peculiar/x509); CA + per-host leaf keys never touch disk.

Limitations (preview)

Documented in the guide: same-host/same-user (not a sandbox); response scrubbing is best-effort (compressed or >2 MB bodies pass through unscrubbed); injection requires a verified public-TLS host; the live-policy reload channel is unauthenticated on a shared uid; approvals are a TTY-only preview; only proxy-aware clients are covered.

Tests

Env-graph proxy decorators, runtime TLS/MITM behavior (inject/scrub/CONNECT, DNS-poison, cleartext guard, SSE), per-call policy, the audit log, approvals + grants, context/fingerprint guards, the session registry, and the resolution-view leak regression. Cert pinning and request re-signing were removed as dead config (re-signing is designed as a future @proxyResign decorator).

let out = '';
while (out.length < length) {
const byte = crypto.randomBytes(1)[0]!;
out += alphabet[byte % alphabet.length];
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website e0e7735 Commit Preview URL

Branch Preview URL
Jun 20 2026, 06:52 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
varlock-docs-mcp e0e7735 Jun 20 2026, 06:50 AM

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

patch Patch releases

  • varlock 1.7.2 → 1.7.3

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/varlock@780

commit: e0e7735

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.
…, cleartext guard, response scrub)

- 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).
…ny + scoped injection)

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.
Record one no-secrets JSON line per proxied request (host, method, path, request-hash, rule, decision, injected keys) under ~/.config/varlock/proxy/audit/<uuid>.jsonl; view with `varlock proxy audit`.
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.
@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.
@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.
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.
…itive vars

Reword the default-omit message as a startup warning explaining the vars were omitted because no proxy policy is set for them.
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).
…ing @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.
…anding 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.
…on cap

@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).
…-defs + decorators)

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.
…o / --new)

proxy run now attaches to a proxy start daemon for the current dir (single cwd-match, or --session <id>) 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.
- 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.
@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.
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.
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).
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).
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).
- `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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants