Local credential proxy for AI agents (proxy MVP)#780
Open
theoephraim wants to merge 64 commits into
Open
Conversation
123c3b6 to
b76e527
Compare
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-website | 5d3400c | Commit Preview URL Branch Preview URL |
Jul 04 2026, 07:03 AM |
9822048 to
9a88dd1
Compare
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-docs-mcp | 5d3400c | Jul 04 2026, 07:00 AM |
Contributor
|
The changes in this PR will be included in the next version bump.
|
commit: |
e0e7735 to
ed0fc5e
Compare
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.
…es=[...])
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.
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.
Add a Sessions section to the guide and a note in the CLI reference: every proxy command operates on a session, targeted with --session <id> 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.
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.
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.
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.
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.
…hlight 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.
…rder Drop the dotted border, retitle to "Credential broker for AI agents" (the established term), and return the lead text to normal size.
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.
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.
@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.
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.
…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.
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.
…uth 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.
…oads; 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).
… 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
… 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.
…ck.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
…ield
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.
- 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)
…ction 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.
- 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
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).
- 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
94daa99 to
5d3400c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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 and audited.
varlock proxy run -- claude # the agent sees a placeholder; the proxy swaps it in on the wireWhat it does
varlock proxycommand + sessions:run,start,rules,env,status,audit,reload,stop. When aproxy startdaemon is already running for the current directory,proxy runattaches to it and adopts that session's resolved env, fetched from the daemon over a token-gated loopback control endpoint. Attaching therefore never re-resolves the schema, never triggers a second unlock prompt, and runs in the session owner's env (its overrides and env selection win over the attaching shell).--session <id>targets one,--newforces a fresh proxy.@sensitiveitem 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-runningvarlock load/printenv/run; it gets the same placeholder back. Opt out per item with@proxy=passthrough(inject the real value) or@proxy=omit(withhold; the key is deleted from the child env even if the launching shell exports it)._VARLOCK_*keys are never proxied. Placeholders are type-aware and unique per item so wire-scrubbing can't confuse two secrets.block(deny) and request-scoped injection, precedence block over allow.@proxyConfig={egress="strict"}blocks any host without a matching@proxyrule; the defaultpermissiveegress lets unmatched hosts through unmodified.varlock proxy audit);varlock proxy rulesconfig summary; a color-coded live request/response log inproxy start.@peculiar/x509): the CA and per-host leaf keys never touch disk.Reload and approvals
varlock proxy reload) hot-swaps a daemon's live policy without a restart. Posture is@proxyConfig={reload="off"|"manual"|"auto"}(defaultauto) plus--allow-reload/--no-allow-reload, resolved at launch from the launcher's own context (an agent can't widen it). In an interactiveproxy startyou can also reload in place with a keypress (r, thenyto confirm). A reload requested from inside the proxied agent is refused and surfaced in the owner's log, so an agent can't self-approve a schema edit to defeat the fingerprint guard. When the on-disk schema drifts from the running policy, the daemon warns (via file-watching) without blocking live traffic.@proxy(approval=true)holds a matching request for an interactive yes/no in theproxy startterminal. A self-contained one-shotproxy runhas no terminal to prompt in and fails closed (403). This is the seam a future out-of-band approver (phone) drops into.Hardening in this pass
varlock.internalendpoint on the existing loopback proxy port serves the child-view env (placeholders, non-secret values, and explicit passthrough values; never the wire-injection real values). The token is a dedicated credential in the 0600 session record, separate from the displayed session id; failed-token and non-loopback probes are refused and logged; passthrough secrets leaving the owner are announced in the live log.@encryptInjectedEnvon spawn:varlock run/varlock proxy runnow honor@encryptInjectedEnvin blob-only inject mode (previously build-time only), so resolved values aren't plaintext in the child's env (leak resistance, not attacker resistance).Limitations (preview)
Documented in the guide: same-host / same-uid, not a sandbox (a determined agent can spawn an out-of-tree process to escape the in-tree guards; pair with an OS sandbox for a real boundary); response scrubbing is best-effort (compressed or >2 MB bodies pass through unscrubbed); injection requires a verified public-TLS host; the default
permissiveegress is not containment; only proxy-aware clients are covered.Tests
Env-graph proxy decorators, runtime TLS/MITM behavior (inject/scrub/CONNECT, DNS-poison, cleartext guard, SSE), the
varlock.internalendpoint (token/peer auth, payload freshness, egress-silence), per-call policy, the audit log, context/fingerprint guards, the session registry, the resolution-view leak regression (incl. fail-closed-on-unreadable-session), the shared child-env assembly (placeholder-wins-over-shell, omit-deletes), and a Bun-runtime check for the verified-TLS invariant (bun run --filter varlock test:proxy:bun, wired into CI): the vitest suite runs under Node and can't catch the Bun-specific pre-write leak.