Local credential proxy for AI agents (proxy MVP)#780
Open
theoephraim wants to merge 84 commits into
Open
Conversation
theoephraim
force-pushed
the
feat/proxy-mvp
branch
from
June 14, 2026 19:50
123c3b6 to
b76e527
Compare
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-website | e6b4b2b | Commit Preview URL Branch Preview URL |
Jul 17 2026, 04:44 AM |
theoephraim
force-pushed
the
feat/proxy-mvp
branch
from
June 16, 2026 22:39
9822048 to
9a88dd1
Compare
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-docs-mcp | 5ba8f4e | Jul 16 2026, 07:23 PM |
Contributor
|
The changes in this PR will be included in the next version bump.
|
commit: |
theoephraim
force-pushed
the
feat/proxy-mvp
branch
2 times, most recently
from
June 26, 2026 06:04
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.
… 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
theoephraim
force-pushed
the
feat/proxy-mvp
branch
from
July 4, 2026 06:59
94daa99 to
5d3400c
Compare
Wrap a self-owned `proxy run` child in a minimal OS credential + egress jail using macOS `sandbox-exec` (built in, no install). The jail keeps the agent, varlock, and integrations working while denying the escape routes: - egress: loopback only, so the proxy on 127.0.0.1 is the child's sole path to the network (a process that scrubs its HTTPS_PROXY env can't bypass it) - credential material: reads of the user varlock dir (encryption key + local-encrypt socket, plugin/credential cache, proxy session/reload channel) - warm credential agents: mach-lookup to the 1Password helper This removes the capability to reach a resolution source, so the same-uid out-of-tree escape (env-scrub + double-fork/setsid to evade process detection) becomes harmless. Opt-in; macOS only for now (Linux/Windows use a container). New `proxy/sandbox-profile.ts` (pure SBPL builder + argv wrapper) is hooked into the single `spawnProxiedChild` spawn path. Tests exercise the real sandbox end-to-end: loopback allowed, non-loopback egress denied (EPERM), fenced credential dir unreadable, and the out-of-tree escape neutralized.
Turn `--sandbox` into an intent with per-platform backends: bare `--sandbox` keeps the built-in macOS seatbelt jail; `--sandbox=docker` (or `=podman`) runs the agent in a container whose only egress is the proxy. Topology derived from first principles (not the host-gateway-bind shape): an `--internal` docker network is a hard egress boundary — a container on it can't reach the host at all — so the proxy must be reachable as a peer on that network. But secrets have to resolve on the host (the encryption key is enclave-gated; a Linux container can't reach it), and we want approvals + warm `op` on the host too. So the real proxy stays on the host and a dumb `socat` forwarder container (no secrets) bridges the agent's internal network to it. The agent holds only placeholders; the host proxy injects the real value onto the wire. - new `sandbox.ts` (backend dispatch), `sandbox-docker.ts` (forwarder topology + guest env/CA wiring), seatbelt module renamed `sandbox-profile` -> `sandbox-seatbelt` - `--sandbox` flag: boolean -> custom (bare -> builtin, `=docker`/`=podman`) - `--sandbox-image` required for the container backend (must contain the command) - container networks + forwarder torn down on exit and on signal - tests: pure wiring/dispatch units + a docker-gated integration test that builds the real topology and asserts egress goes only via the forwarder Verified live against Docker: agent sees a placeholder, HTTPS via the proxy is MITM'd (CA trusted in-guest) and the real secret injected (audit: injected=[API_TOKEN]), direct proxy-bypassing egress fails closed.
…le status Fixes "ghost" sessions, makes stop actually stop, and makes the session commands legible/interactive. - Authoritative liveness: `isProxySessionAlive` probes the session's actual proxy port instead of only `kill(pid,0)`, which PID reuse defeats — a dead proxy's pid gets recycled and the record looks active forever (why two sessions showed for one cwd). cleanupStaleProxySessions marks those ended. - Reliable stop: `proxy stop` now VERIFIES the process died and escalates to SIGKILL if it didn't. A daemon could wedge in its SIGTERM cleanup — `server.close()` blocks forever on idle keep-alive connections — so "sent SIGTERM" wasn't "stopped". Root-fixed too: runtime.stop() force-closes connections, and the daemon bounds cleanup with a timeout then force-exits, so a plain SIGTERM now terminates it. stop only signals when the port confirms the session is live (never a reused pid). - Clean up a specific session by id: `proxy stop --session <id>` targets ANY session (live, ghost, already-ended); `proxy prune --session <id>` deletes one record (refuses if running). Explicit-id lookups include ended records so a session is always addressable by id. - `proxy status` is an aligned, colorized table (state · id · pid · port · egress · req/match/block · last-activity · dir); brand-new sessions show `new`, `--format json` for machines, and a hint about hidden ended sessions. - `proxy prune` deletes ended session records (with confirm; `-y` to skip). - Interactive disambiguation: >1 match (attach, or `proxy stop` with no --session) prompts a picker on a TTY (with a `new`/`idle`/`active` hint); clear error for scripts. - Export ExecResult from lib/exec so the dts build can name the docker backend's return type (unblocks `build:libs`). Tests: liveness (ghost vs live port), target-by-id incl. ended, single + bulk prune, retention — all green.
…t secret values in examples - Prefer @sensitive=false (house style) as the way to mark a non-secret, with @public mentioned once as the shorthand. - Replace secret-looking literals (sk-..., sk_live_...) in schema examples with a resolver (yourPreferredPlugin()) so readers aren't nudged toward hardcoding a secret in the schema.
Approval isn't documented anywhere yet, so remove the one-shot fail-closed caution and the stray `approval` field / "interactive approvals" mentions until approval is properly documented.
…ullets Show only the short session id (magenta), and split the targeting + reload guidance into two dim, indented bullet lines instead of dense run-on prose.
… clearer block messages Previously `egress="strict"` only enforced at host granularity — a request to a host that had a `@proxy` rule but where no rule matched this path/method passed through unproxied (a silent gap vs the documented "only matching requests are allowed"). Now the request-level policy is egress-aware: - strict + no allow rule matches this method/path → deny (`egress-strict`), with a message that says the host has a rule but none matches this method + path. - block rules still always win over allow rules, in either egress mode. - the explicit-block message now names the method + host + path. `evaluateProxyPolicy` takes the egress mode and returns a `denyKind` (`block` | `egress-strict`) so the runtime picks the right message + audit decision (`deny` vs `blocked-egress`). Docs updated to state strict blocks non-matching endpoints and that block always wins over allow. Tests added.
…rough rules-engine tests Keeps the single-dial egress model (permissive = allow unmatched, strict = allowlist) but fixes its bad failure mode: in permissive mode a placeholder that no rule injects on a route used to pass through and 401 upstream with no clue that the proxy rule was the cause. - New guard: when NO rule injects anything on a route yet the request carries a managed placeholder, block with a message naming the item + the rule gap (audit decision `blocked-uninjected`). Scoped to `hostItems.length === 0` so a request that DOES inject can still carry an unrelated item's placeholder (bound for a different host) through untouched — preserving the no-cross-host-leak test. - `findUninjectedPlaceholder` helper, unit-tested. - Rules-engine test suite rewritten into a thorough spec: domain globs (apex + subdomain, look-alike/suffix-confusion negatives), path globs (`*` vs `**`, anchoring, literal regex metachars), method matching, allow<approval<block precedence, the egress model (blanket opens a domain, scoped rules allowlist, block always wins), and per-rule key scoping — a key injects only on its own rule's route, never for the domain in general.
The proxy is a single unreleased feature with one changeset, so mention `--sandbox` there instead of adding separate files (the strict-egress and helpful-failure entries described fixes to unreleased behavior, so they carry no changelog value).
Brings the branch up to date with main (21 commits). Conflicts resolved: - `data-types.ts` / `config-item.ts`: additive on both sides — kept the proxy's `generatePlaceholder` alongside main's `coercedType`, and the dual-form-decorator check alongside main's eager `@tag(...)` resolution. - `env-graph.ts`: kept the proxy's flattened `overrideKeys` (main's nested `__varlockOverrideMeta` was superseded by it; `load-graph`/`injected-env-provenance` depend on the flat shape) plus main's filterKeys rationale. Updated main's serialized-graph-filter test to the flat field. - `run.command.ts`: kept main's `--filter` ambient-carry strip; dropped its inline redaction logic (the shared `resolveStdoutRedaction` helper is behaviorally identical) and its blob encryption-key block (superseded by `buildInjectedBlobEnv`, which also honors `@encryptInjectedEnv`). - `load.command.ts`: took main's security fix (@internal excluded from json-full by default + filterKeys) and kept the proxy runtime annotation. - `proxy.command.ts`: `generateTypesIfNeeded` -> `runCodeGeneratorsIfNeeded` (renamed by main's codegen registry). Semantic conflict git could not see: main added a guard rejecting a decorator name present in both the item and root registries, which is exactly what `@proxy` needs (detached header rules + attached item rules) and which this branch's placement validation already handles. Rather than drop a useful guard against accidental collisions, dual placement is now an explicit opt-in (`allowDualPlacement`, required on both defs) that only `@proxy` sets.
The test spins up real containers and pulls images from Docker Hub, so gating it on "the docker CLI exists" wasn't enough — on the shared ubuntu runner the pulls are rate-limited/flaky and the agent container never ran, which surfaced as a bare `ENOENT: .../out.txt` (the missing mounted output) rather than the actual docker error. That failure says nothing about the code under test. - Gate on `VARLOCK_TEST_DOCKER=1` (plus docker being available). The pure `buildContainerWiring` tests still run everywhere; the real topology runs on demand. - Report the underlying child/docker error when the container produced no output, instead of an ENOENT from readFileSync.
…y-downgrade Seatbelt --sandbox: a file-read/write deny does not gate a unix-socket connect(), so the local-encrypt daemon socket under the varlock dir stayed reachable and an escaped child could have the warm daemon decrypt secrets. Add a path-scoped `(deny network-outbound (remote unix-socket (subpath ...)))` per denyPath, emitted after the broad unix allow so it wins (last-match-wins). System sockets (DNS, syslog) and loopback egress stay working. Approval rules: the policy verdict is decided by the most-specific rule tier, but request-scoped item injection unioned keys from every matching non-block rule. A more-specific plain-allow rule (e.g. exempting /health) yielded an allow verdict and skipped the approval gate, yet a broader @Proxy(approval) rule's key still injected with no prompt. Withhold approval-gated keys unless the verdict routes through the approval gate; a key also reachable via a plain-allow rule stays unconditional. Adds unit + integration coverage for both, and corrects the sandbox guide.
The built-in --sandbox jail did not deny the keychain daemons, so a jailed agent could read any uid-accessible keychain secret directly via `security` / SecItemCopyMatching (verified: a jailed `security find-generic-password -w` returns the secret). varlock's own keychain() resolver routes through the daemon socket (already fenced), but the direct securityd path was open. Deny com.apple.securityd and com.apple.SecurityServer by exact global-name. com.apple.trustd is left allowed on purpose: on modern macOS the daemons are split (securityd/SecurityServer = keychain secrets, trustd = TLS trust), so this closes the secret-read path while native Security.framework TLS keeps working (verified: curl SecureTransport and Swift URLSession both complete an HTTPS handshake with securityd denied). Adds a denyMachNames input for exact names alongside the existing prefix list.
Proxy examples showed plaintext secret-shaped values on the right-hand side (STRIPE_KEY=sk_live_..., OPENAI_API_KEY=sk-..., WEBHOOK_SECRET=whsec_..., LEGACY_TOKEN=...). Replace them with yourPreferredPlugin() so the examples model sourcing the value from a plugin or built-in encryption, never a plaintext secret. Placeholder values (@Placeholder=sk-proj-000...) are left as-is since that fake value is the whole point.
The proxy command hand-rolled dispatch: it read positionals[0] and ran a switch over run/start/rules/env/status/audit/reload/stop/prune, with every verb's flags lumped into one flat args bag (each description hedged with which verb it applied to). That gave confusing per-command help (proxy audit --help listed --sandbox etc.) and no shell completion for the verbs. Register each verb as a native gunshi subCommand (matching cache/keychain), so each declares only the flags it reads. Shared flags (--session/--path/ --allow-reload) are small fragments spread into the verbs that use them. Action bodies are unchanged; getRunCommandArgs still reads process.argv for the `-- <cmd>` passthrough. No change to the command surface; strictFlags now rejects a flag passed to a verb that doesn't declare it (previously ignored).
`varlock run --proxy` never shipped, so there's no migration to guard against. Remove the check (and its now-unused CliExitError import).
…he def
The shared decorator handler hardcoded per-decorator knowledge:
`this.name === 'sensitive' ? '{preventLeaks=false}' : '{egress="strict"}'`. Add
an `objectValueExample` field to the decorator def and have the generic handler
read it, so `@sensitive` and `@proxyConfig` each carry their own example and the
shared path has no decorator-specific branches. Adding another such decorator is
now a one-line def change.
The example is also put to use: a bare `@name()` now suggests the def's example
(`@proxyConfig={egress="strict"}`) instead of the useless `@name={}`; a call with
options still echoes those back. Adds tests covering both a root and an item
decorator, and the echo-vs-example split.
By default the proxy binds a random loopback port and writes its CA cert to a temp dir, so anything downstream can only learn them after startup (via `proxy env`). Add `--port` and `--cert-dir` so a caller can fix both up front and wire tools to a known HTTP(S)_PROXY / CA path before the proxy boots. - `--port <n>`: bind a fixed loopback port; fails closed (clear error) if it's already in use rather than falling back to a random one. - `--cert-dir <dir>`: write ca-cert.pem + combined-ca.pem into a known dir (created if missing); on stop only the cert files are removed, not the dir. Both only apply when starting a proxy; on a `proxy run` that attaches to an existing daemon they're rejected (that daemon already fixed them).
The dep was declared `"^1"` (no real floor, and a major behind). Move to v2 and
pin it. v2's one behavior change ("reflect polyfill required") is already
satisfied: cert-authority's lazy loader imports `reflect-metadata` before
`@peculiar/x509`. Only the cert-authority test needed a fix — it imports x509
statically, so it now pulls the polyfill first too, matching production.
Validated under v2: typecheck, the cert-authority + proxy-tls suites, the Bun
verified-TLS invariant check, and — the load-bearing one for the
reflect-metadata/tsyringe bundling — minting a CA from the `bun --compile`
binary via `proxy run`.
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;--port/--cert-dirpin the loopback port and CA cert location so tools can be wired to a known endpoint before the proxy starts.@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 — a key is injected only on the routes its own rule matches, never for the domain in general, and precedence is allow < approval < block (block always wins).@proxyConfig={egress="strict"}is an allowlist: only requests matching a@proxyrule pass, including on a routed host whose path/method doesn't match (previously those slipped through unproxied). The defaultpermissiveegress lets unmatched requests through — but if a request carries a placeholder that no rule injects on that route, the proxy blocks it and names the rule gap, instead of forwarding a doomed request and letting the upstream reject it with a mystery 401.varlock proxy audit);varlock proxy rulesconfig summary; a color-coded live request/response log inproxy start.stopcould SIGTERM an unrelated process).proxy stopnow verifies the process actually exited and escalates to SIGKILL if a daemon wedges in cleanup;proxy statusis a readable table (state · id · pid · port · egress · counts · created/last · dir,--format jsonfor machines);proxy prunedeletes ended records; an ambiguous target prompts an interactive picker on a TTY.@peculiar/x509): the CA and per-host leaf keys never touch disk.Sandbox
On its own the proxy is same-uid, so its in-tree guards (placeholder isolation, the fingerprint check) raise the bar but are not a boundary — a determined agent can env-scrub +
setsidout of the process tree and reach a secret's source directly.proxy run --sandboxcloses that by removing the capability rather than detecting the process:--sandbox— macOSsandbox-exec(built in, no install): a credential + egress jail. Loopback-only egress (the proxy becomes the child's sole path to the network, so scrubbingHTTPS_PROXYdoesn't help), denied reads of the user varlock dir (encryption key, caches, the session/reload channel) and unix-socket connections into it (a file-read deny doesn't gate a socketconnect(), so the local-encrypt daemon socket is fenced separately); and deniedmach-lookupto the macOS keychain daemons (securityd/SecurityServer, so a jailed agent can't read the keychain directly —trustdstays allowed so TLS still works) and warm credential agents. The macOS sandbox is inherited by every child, so the out-of-tree escape stays jailed.--sandbox=docker(=podman) — the agent runs in a container on an--internalnetwork with no route out at all; a dumbsocatforwarder (holding no secrets) bridges that network to the host proxy. Secrets never enter a container: the host proxy resolves them (keeping the enclave-gated key, keychain, and warmopsession on the host) and injects at the wire. Networks + forwarder are torn down on exit. Needs--sandbox-image <img>(varlock can't know your toolchain).Opt-in by design. Linux gets the container backend today; a built-in Linux jail (Landlock/netns) is follow-up.
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: without
--sandboxit is same-host / same-uid and not a boundary (a determined agent can spawn an out-of-tree process to escape the in-tree guards) —--sandboxor a container closes this; response scrubbing is best-effort (compressed or >2 MB bodies pass through unscrubbed); injection requires a verified public-TLS host; the defaultpermissiveegress 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), a thorough rules-engine suite (domain globs incl. suffix-confusion negatives, path globs*vs**/anchoring/literal metachars, method matching, allow<approval<block precedence, the egress model, and per-rule key scoping), the sandbox backends (realsandbox-execjail incl. the out-of-tree escape payload, and a docker-gated topology test), 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.