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/.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/.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 diff --git a/AGENTS.md b/AGENTS.md index bebbeebec..0ff713d43 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, two sentences, or a plain hyphen (`-`) where that reads naturally. Only the em/en dash characters are banned; a regular hyphen is fine. 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-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 new file mode 100644 index 000000000..181f8a6e4 --- /dev/null +++ b/packages/varlock-website/src/content/docs/guides/proxy.mdx @@ -0,0 +1,355 @@ +--- +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 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**: 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. + +
+ + + + + + + + + + + + + + + + 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. 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. +::: + +```diff lang="env-spec" title=".env.schema" +# @sensitive ++# @proxy(domain="api.openai.com") @placeholder=sk-proj-000000000000000000000000 +OPENAI_API_KEY=yourPreferredPlugin() # load from secure vault +``` + +```bash +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 `@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 + +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 + +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. 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" +# @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)). + +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) + +```bash +varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started +``` + +#### 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. + +:::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). +::: + +## Routing rules + +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`. | +| `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). | +| `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: + +```env-spec title=".env.schema" +# @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) +# --- +# Match either host, any method: +# @sensitive +# @proxy(domain=[api.stripe.com, api.stripe-test.com]) +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" +# @proxyConfig={egress="strict"} +# --- +# @sensitive +# @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])`.) +- **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 + +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 `@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 + +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 → 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. + +:::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) and pair the proxy with a sandbox for a real boundary.) + +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 # withhold entirely: absent from the child env, and + # resolves to "unset" (not the real value) if re-resolved +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 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: + +```env-spec title=".env.schema" +# @sensitive +# @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 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 +# 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, 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 +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. + +## 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` / `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. + +## 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. + +**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. +::: + +## 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 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 +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. 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. +::: + +## 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 *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 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 + +- [`@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/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index fa4c56560..5c04cd625 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,54 @@ 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. Route secrets by adding [`@proxy(domain=...)`](/reference/item-decorators/#proxy) to the items you want to protect. + +```bash +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`. +- `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). +- `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` / `--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. +- `--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 +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..27f3c26e5 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,53 @@ 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. Using `@proxy(...)` on an item implies [`@sensitive`](#sensitive). + +**Function form** `@proxy(domain=..., [path], [method], [block], [approval], [keys], [rules])`: + +| Option | Meaning | +|---|---| +| `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. | +| `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). | + +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. + +```env-spec +# @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..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,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. +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 - `true`: Blob is encrypted, `_VARLOCK_ENV_KEY` required at build time and runtime @@ -485,4 +487,50 @@ API_KEY= ``` +
+### `@proxyConfig` +**Value type:** `{egress?: "permissive" | "strict", reload?: "off" | "manual" | "auto"}` + +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. + +`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", reload="manual"} +# --- +# @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 | 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=[...]`. + +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 +# 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: +# @proxy(domain="api.stripe.com", keys=[STRIPE_KEY, WEBHOOK_SECRET]) +# --- +# @sensitive +STRIPE_KEY=sk_live_... +# @sensitive +WEBHOOK_SECRET=whsec_... +``` +
+ diff --git a/packages/varlock-website/src/pages/index.astro b/packages/varlock-website/src/pages/index.astro index ebd1245ef..565aa6abc 100644 --- a/packages/varlock-website/src/pages/index.astro +++ b/packages/varlock-website/src/pages/index.astro @@ -327,6 +327,96 @@ curl -sSfL https://varlock.dev/install.sh | sh -s Use varlock run to inject resolved, validated env vars into another process.

+ +
+

+ + 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. You run the broker + yourself, on the same host as the agent, so the real secrets never go to a third party. +

+ +
+ + + + + + + + + + + + + + + + Agent + untrusted child + holds placeholders + varlock proxy + local, in memory + swap / verify TLS / scrub + Upstream API + verified TLS host + + + + + + + + + + + placeholder + real secret + scrubbed + response + + +
+ +
+
+ + +
+
    +
  • + 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 +
+
@@ -559,6 +649,46 @@ curl -sSfL https://varlock.dev/install.sh | sh -s font-weight: bold; } + /* Credential broker feature highlight */ + .proxy-feature { + margin: 3.5rem 0 1rem; + } + .proxy-lead { + line-height: 1.5; + 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; 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' }, ], }, { diff --git a/packages/varlock/package.json b/packages/varlock/package.json index cb1826670..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" @@ -142,6 +143,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 +159,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/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/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index a864e2c9f..86eb139b0 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'; @@ -32,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'; @@ -76,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'))); @@ -108,6 +111,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/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'; } } diff --git a/packages/varlock/src/cli/commands/load.command.ts b/packages/varlock/src/cli/commands/load.command.ts index e1899653a..c52f7c9e5 100644 --- a/packages/varlock/src/cli/commands/load.command.ts +++ b/packages/varlock/src/cli/commands/load.command.ts @@ -10,6 +10,12 @@ 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'; +import { getActiveProxySession } from '../../proxy/session-registry'; export const commandSpec = define({ name: 'load', @@ -192,6 +198,19 @@ 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 }); + // 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: proxySession?.id ?? process.env[PROXY_SESSION_ID_ENV_VAR], + sessionUuid: proxySession?.uuid ?? 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..b0757adf9 --- /dev/null +++ b/packages/varlock/src/cli/commands/proxy.command.ts @@ -0,0 +1,1905 @@ +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'; + +import ansis from 'ansis'; +import { define } from 'gunshi'; +import { gracefulExit } from 'exit-hook'; + +import { exec } from '../../lib/exec'; +import { loadVarlockEnvGraph } from '../../lib/load-graph'; +import { startLocalProxyRuntime, type ProxyResponseInfo } from '../../proxy/runtime-proxy'; +import { + createProxyAuditLog, + readProxyAuditLines, + type ProxyActivity, + type ProxyAuditEntry, + type ProxyAuditLog, +} from '../../proxy/audit'; +import { + createAutoDenyApprovalProvider, + createTtyApprovalProvider, + type ApprovalProvider, +} from '../../proxy/approval'; +import { + createApprovalGrantStore, + createGrantingApprovalProvider, + type ApprovalGrantStore, +} from '../../proxy/approval-grants'; +import { + addProxySessionAttachment, + cleanupStaleProxySessions, + createProxySessionRecord, + markProxySessionEnded, + getProxySessionByToken, + getProxySessionExportEnv, + isProcessRunning, + listProxySessions, + removeProxySessionAttachment, + reserveProxySessionIdentity, + resolveProxySessionForCommand, + updateProxySessionRecord, + type ProxySessionStats, + type ProxySessionRecord, +} from '../../proxy/session-registry'; +import { + PROXY_PARENT_PID_ENV_VAR, +} from '../../proxy/env-vars'; +import { + newReloadRequestId, + readReloadRequest, + readReloadResult, + writeReloadRequest, + writeReloadResult, + type ProxyReloadRequest, + type ProxyReloadResult, +} 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, + 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'; +import { resetRedactionMap } from '../../runtime/env'; +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 { + 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: `proxy env` → shell (default) or json; `proxy audit` → text (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)', + }, + ...REDACT_STDOUT_ARG, + inject: { + type: 'string', + 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', + }, + 'allow-reload': { + type: 'boolean', + 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: ` +Proxy command surface: + 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 rules # summarize the effective @proxy config (no proxy started) + varlock proxy env --session abc12 + varlock proxy status + varlock proxy audit --session abc12 + varlock proxy reload --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'; + /** + * 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 = { + 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; + } + }, + }; +} + +/** 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; +} + +/** + * 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 async function computeProxyChildView( + envGraph: Awaited>, + proxyManagedItems: Array, +): Promise<{ placeholderByKey: Record; omittedKeys: Array }> { + const managedKeys = new Set(proxyManagedItems.map((item) => item.key)); + 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) continue; + const mode = getProxyValueMode(item); + if (mode === 'passthrough') continue; // inject the real value + if (mode === 'omit') { + omittedKeys.push(key); + 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 + } + + return { placeholderByKey, omittedKeys }; +} + +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, rules, env, status, audit, reload, 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; +} + +/** + * 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 + // its own loads to the nested-context guard (would block `proxy reload`). + skipProxyFingerprintGuard: true, + }); + checkForSchemaErrors(envGraph); + checkForNoEnvFiles(envGraph); + + await envGraph.generateTypesIfNeeded(); + await envGraph.resolveEnvValues(); + checkForConfigErrors(envGraph); + return envGraph; +} + +async function prepareProxyPolicy(entryFilePaths?: Array): Promise { + const envGraph = await loadResolvedProxyGraph(entryFilePaths); + + const resolvedEnv = envGraph.getResolvedEnvObject(); + const serializedGraph = envGraph.getSerializedGraph(); + const schemaFingerprint = buildProxySchemaFingerprint(envGraph); + const proxyManagedItems = await envGraph.getProxyManagedItems(); + const proxyRules = await envGraph.getProxyRules(); + + const genericPlaceholderKeys = proxyManagedItems + .filter((item) => 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.', + ); + } + + // 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 [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, + serializedGraph, + schemaFingerprint, + proxyManagedItems, + proxyRules, + egressMode: serializedGraph.settings?.proxyEgress ?? 'permissive', + placeholderByKey, + omittedKeys, + }; +} + +/** + * Assemble the env for a proxied child from a session-env payload. Pure (no + * process access) so the layering rules are unit-testable: + * - payload values (placeholders included) win over the launching shell's + * ambient values, so a real key accidentally exported in the shell can't + * reach the child when the schema manages it + * - omitted keys are absent entirely, even if the shell exports them + */ +export function buildProxiedChildEnv(opts: { + payload: SessionEnvPayload; + sessionExportEnv: Record; + 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', + // 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 + // launching/attaching shell happens to export a value for it. + for (const key of opts.payload.omittedKeys) { + delete fullInjectedEnv[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) + * 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.'); + } + if (!session.endpointToken) { + throw unreachable('Its record has no endpoint token.'); + } + + 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.endpointToken, + }, + }, (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. +let activeApprovalPrompts = 0; +const deferredProxyLogLines: Array = []; + +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); +} + +/** 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); + 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 ? 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 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: { + policy: PreparedProxyPolicy; + entryPaths?: Array; + command?: Array; + 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 reload` 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; + statsWriter: ReturnType; + auditLog: ProxyAuditLog; + 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 + // 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. + 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, + approvalProvider, + onActivity: (activity) => { + statsWriter.onActivity(activity); + auditLog.record(activity); + if (opts.logRequests) emitProxyLog(formatProxyRequestLog(activity)); + }, + onResponse: opts.logRequests + ? (info) => emitProxyLog(formatProxyResponseLog(info)) + : undefined, + 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)), + { passthroughCount: countPassthroughSecrets(opts.policy) }, + ); + const session = await createProxySessionRecord({ + id: identity.id, + uuid: identity.uuid, + ownerPid: process.pid, + 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 } : {}), + 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 } : {}), + ...(opts.reloadable ? { reloadable: true } : {}), + }); + + return { + runtime, session, statsWriter, auditLog, grantStore, + }; +} + +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}${state}`; +} + +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]; + } + + // `proxy status` shows active sessions by default; `--all` also includes the + // durable records of ended sessions. + return await listProxySessions({ includeEnded: !!ctx.values.all }); +} + +/** True when `cwd` is the session's directory or a subdirectory of it. */ +export function isCwdWithin(cwd: string, sessionCwd: string): boolean { + if (cwd === sessionCwd) return true; + const rel = path.relative(sessionCwd, cwd); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +export type ReloadMode = 'off' | 'manual'; +export type ReloadSetting = 'off' | 'manual' | 'auto'; + +/** + * Resolve the effective reload posture at launch. Precedence: the + * `--allow-reload` / `--no-allow-reload` flag, then `@proxyConfig={reload=...}`, + * then the default `auto`. + * + * `auto` errs conservative today: a human-applied (`manual`) reload only for an + * interactive `proxy start` (a person is there to run `proxy reload` and watch the + * refusal log), and `off` for headless runs or a one-shot `proxy run` (which + * re-reads the schema on its next invocation anyway). `auto` is agent-resistant + * because it reads the launcher's context, which the not-yet-running agent can't + * influence, and it only ever widens when it is safe to: + * TODO(sandbox-detect): a detected sandbox closes the escape, so allow agent-triggered reload. + * TODO(approval): an out-of-band approver resolves `auto` to an `approval` mode. + */ +export function resolveReloadMode(opts: { + flag: boolean | undefined; + schema: ReloadSetting | undefined; + isStart: boolean; + hasTty: boolean; +}): { mode: ReloadMode; resolvedFromAuto: boolean } { + let setting: ReloadSetting; + if (opts.flag === true) setting = 'manual'; + else if (opts.flag === false) setting = 'off'; + else setting = opts.schema ?? 'auto'; + + if (setting !== 'auto') return { mode: setting, resolvedFromAuto: false }; + return { mode: opts.isStart && opts.hasTty ? 'manual' : 'off', resolvedFromAuto: true }; +} + +/** Whether a human terminal is attached, the signal reload `auto` uses. */ +function isHumanAttended(): boolean { + return !!(process.stdout.isTTY || process.stderr.isTTY || process.stdin.isTTY); +} + +/** + * 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. + * + * 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): Promise { + await cleanupStaleProxySessions(); + + if (ctx.values.session) { + return resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: false, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + } + + 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.' }, + ); + } + return matches[0]; +} + +/** + * 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, + }); + // 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)), + { passthroughCount: countPassthroughSecrets(next) }, + ); + 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. */ +const RELOAD_RESULT_POLL_INTERVAL_MS = 250; +const RELOAD_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 reload` 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 (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', + }; + } + // 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 () => { + 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); + 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) }; +} + +/** + * 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]!; + const commandArgsOnly = commandToRunAsArgs.slice(1); + const commandToRunStr = commandToRunAsArgs.join(' '); + + // 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); + + 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) { + // 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 — + // 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 () => { + 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. + 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, + 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(), + 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 reload` 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(); + await created.statsWriter.flushNow(); + created.statsWriter.stop(); + await created.runtime.stop().catch(() => undefined); + await created.auditLog.flush().catch(() => undefined); + await markProxySessionEnded(session.uuid).catch(() => undefined); + }; + } + + const { injectVars, injectBlob } = resolveInjectMode(ctx.values.inject); + const commandProcess = spawnProxiedChild({ + payload, + session, + rawCommand, + commandArgsOnly, + injectVars, + injectBlob, + redactStdoutFlag: ctx.values['redact-stdout'], + redactionManagedItems, + }); + + if (commandProcess.pid && !attachSession) { + 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(); + 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); +} + +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 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'], + schema: policy.serializedGraph.settings?.proxyReload, + isStart: true, + hasTty: isHumanAttended(), + }); + const allowReload = reloadMode === 'manual'; + const { + runtime, session, statsWriter, auditLog, + } = 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 — and session/duration + // 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: allowReload, + // The daemon owns this terminal, so tail a live per-request/response log here. + logRequests: true, + }); + + 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) { + 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(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 + // runs when reload is explicitly enabled. + const reloadServicer = allowReload + ? startReloadServicer({ + session, + runtime, + defaultEntryPaths: ctx.values.path, + log: (message) => console.log(message), + }) + : 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); + await auditLog.flush().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) => { + const close = () => { + cleanup().finally(resolve); + }; + + 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, + }); + } + }); +} + +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 reloadAction(ctx: any) { + const session = await resolveProxySessionForCommand({ + explicitSession: ctx.values.session, + env: process.env, + defaultToSingleActive: true, + }).catch((error) => { + throw new CliExitError((error as Error).message); + }); + + // 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} has hot-reload disabled.`, + { + 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.', + }, + ); + } + if (!isProcessRunning(session.ownerPid)) { + throw new CliExitError(`Proxy session ${session.id} is no longer running.`); + } + + // 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. 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 loadResolvedProxyGraph(reloadPaths).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(); + // 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; + const onInterrupt = () => { + interrupted = true; + }; + process.on('SIGINT', onInterrupt); + process.on('SIGTERM', onInterrupt); + + const deadline = Date.now() + RELOAD_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) { + // `done`/`error` are both terminal — the servicer only writes a result once. + 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, RELOAD_RESULT_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 === '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'}`); + } + + 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 pick up new 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) { + 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 markProxySessionEnded(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 markProxySessionEnded(session.uuid); + console.log(`Marked stale proxy session ${session.id} ended.`); + } +} + +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 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({ + 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)); + } +} + +/** 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); + + switch (action) { + case 'run': + return await runAction(ctx); + case 'start': + return await startAction(ctx); + case 'rules': + return await rulesAction(ctx); + case 'env': + return await envAction(ctx); + case 'status': + return await statusAction(ctx); + case 'audit': + return await auditAction(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, reload, stop' }, + ); + } +}; diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 7fe9d6ef5..4f4d44f5a 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -7,6 +7,9 @@ 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'; +import { buildInjectedBlobEnv } from '../helpers/injected-env-blob'; +import { resolveInjectMode } from '../helpers/inject-mode'; export const commandSpec = define({ name: 'run', @@ -17,11 +20,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 +76,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 @@ -164,6 +150,9 @@ function signalChild(signal: NodeJS.Signals | number) { export const commandFn: TypedGunshiCommandFn = async (ctx) => { // 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('--'); @@ -205,7 +194,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 @@ -214,19 +202,19 @@ 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, ...(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 @@ -238,39 +226,12 @@ 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; - } - - 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 @@ -342,18 +303,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/commands/test/proxy.command.test.ts b/packages/varlock/src/cli/commands/test/proxy.command.test.ts new file mode 100644 index 000000000..e1c79850b --- /dev/null +++ b/packages/varlock/src/cli/commands/test/proxy.command.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, test } from 'vitest'; +import outdent from 'outdent'; +import { DotEnvFileDataSource, EnvGraph } from '../../../env-graph'; +import { + buildProxiedChildEnv, computeProxyChildView, isCwdWithin, resolveReloadMode, createReloadKeypressHandler, +} 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; +} + +async function childView(graph: EnvGraph) { + const managedItems = await graph.getProxyManagedItems(); + 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('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` + # @defaultSensitive=false + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + PROXIED_SECRET=secret-proxied + + # @sensitive + UNMANAGED_SECRET=secret-unmanaged + `); + + 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('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 + # --- + # @sensitive + # @proxy=passthrough + PASS_SECRET=secret-allowed + `); + + 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 () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + # @proxy=omit + PLAIN_BUT_OMITTED=whatever + + # @sensitive + # @proxy=omit + SECRET_OMITTED=secret + `); + + 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 touch 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 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 placeholder/omit non-sensitive keys with no policy', async () => { + const graph = await loadGraph(outdent` + # @defaultSensitive=false + # --- + PLAIN=just-a-value + + # @sensitive + # @proxy(domain="api.example.com") + PROXIED_SECRET=secret-proxied + `); + + const view = await childView(graph); + expect(view.placeholderByKey.PLAIN).toBeUndefined(); + expect(Object.keys(view.placeholderByKey)).toEqual(['PROXIED_SECRET']); + expect(view.omittedKeys).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 + }); +}); + +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` + # @proxyConfig={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` + # @proxyConfig={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` + # @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/); + }); +}); + +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('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, + 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/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/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/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 } : {}), + }; +} 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..9a592d0fd --- /dev/null +++ b/packages/varlock/src/cli/helpers/proxy-context-guard.ts @@ -0,0 +1,69 @@ +import { CliExitError } from './exit-error'; +import { createDebug } from '../../lib/debug'; +import { getActiveProxySession } from '../../proxy/session-registry'; +import { + PROXY_CHILD_ENV_VAR, +} from '../../proxy/env-vars'; + +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. + */ +export async function isInProxyContext(env: NodeJS.ProcessEnv = process.env): Promise { + if (isProxyChildProcess(env)) return true; + + const session = await getActiveProxySession(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 (!(await isInProxyContext(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` 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.)', + }, + ); + } + 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 proxy run -- ...`, or request explicit user approval in a trusted context.', + }, + ); + } +} 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..1e88bf3a6 --- /dev/null +++ b/packages/varlock/src/cli/helpers/proxy-schema-fingerprint.ts @@ -0,0 +1,132 @@ +import { createHash } from 'node:crypto'; + +import { + ParsedEnvSpecArrayLiteral, + ParsedEnvSpecFunctionArgs, + ParsedEnvSpecFunctionCall, + ParsedEnvSpecKeyValuePair, + ParsedEnvSpecObjectLiteral, + ParsedEnvSpecStaticValue, +} from '@env-spec/parser'; + +import type { EnvGraph } from '../../env-graph'; +import { CliExitError } from './exit-error'; +import { getActiveProxySession } from '../../proxy/session-registry'; +import { + PROXY_CHILD_ENV_VAR, + PROXY_SCHEMA_FINGERPRINT_ENV_VAR, +} from '../../proxy/env-vars'; + +/** + * 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(',')})`; + } + if (node instanceof ParsedEnvSpecArrayLiteral) { + // 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) { + const entries = node.values.map((kv) => `${kv.key}=${canonicalParsed(kv.value)}`); + entries.sort(); + return `{${entries.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, 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 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]; + 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({ rootDecorators, items }); + + 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-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). + * + * 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 { + const session = await getActiveProxySession(env).catch(() => undefined); + + // 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]; + } + if (!expected) return; + + const actual = buildProxySchemaFingerprint(envGraph); + if (actual === expected) return; + + throw new CliExitError('Schema changed inside an active proxy session.', { + 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/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/proxy-context-guard.test.ts b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts new file mode 100644 index 000000000..66914c378 --- /dev/null +++ b/packages/varlock/src/cli/helpers/test/proxy-context-guard.test.ts @@ -0,0 +1,49 @@ +import { + describe, expect, test, +} from 'vitest'; + +import { CliExitError } from '../exit-error'; +import { + enforceProxyContextGuards, +} from '../proxy-context-guard'; + +describe('enforceProxyContextGuards', () => { + const proxiedEnv = { __VARLOCK_PROXY_CHILD: '1' } as NodeJS.ProcessEnv; + const normalEnv = {} as NodeJS.ProcessEnv; + + test('does nothing outside proxied context', async () => { + await expect(enforceProxyContextGuards(['reveal'], normalEnv)).resolves.toBeUndefined(); + }); + + test('allows nested run in proxied context', async () => { + await expect(enforceProxyContextGuards(['run', '--', 'echo', 'x'], proxiedEnv)).resolves.toBeUndefined(); + }); + + 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 commands in proxied context', async () => { + await expect(enforceProxyContextGuards(['load'], proxiedEnv)).resolves.toBeUndefined(); + await expect(enforceProxyContextGuards(['load', '--format', 'pretty'], proxiedEnv)).resolves.toBeUndefined(); + 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 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('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', 'reload'], proxiedEnv)).resolves.toBeUndefined(); + }); +}); 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..de1dc78bf --- /dev/null +++ b/packages/varlock/src/cli/helpers/test/proxy-schema-fingerprint.test.ts @@ -0,0 +1,94 @@ +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` + # @proxyConfig={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` + # @proxyConfig={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` + # @proxyConfig={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('a single value and a single-element array are identical (domain=a ≡ domain=[a])', async () => { + const single = outdent` + # @proxyConfig={egress="strict"} + # --- + # @sensitive @proxy(domain="api.x.com") + SECRET=abc + `; + const arrayOfOne = outdent` + # @proxyConfig={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)); + }); + + test('flipping proxied → passthrough changes it (the gap the old shape-only fingerprint missed)', async () => { + const passthrough = outdent` + # @proxyConfig={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/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 }); + }); +}); 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 0b6417bc9..5573b0d5a 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; @@ -458,13 +468,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 +496,28 @@ 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) { + // 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'; + } + } + + private async resolveSensitiveSource() { const sensitiveFromDataType = this.dataType?.isSensitive; // Pass 1: explicit per-item @sensitive / @public decorators take highest priority @@ -650,6 +682,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) { @@ -882,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/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/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index d5e62d790..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,6 +32,16 @@ type EnvGraphDataTypeDef MaybePromise<(true | undefined | void | Error | Array)>; + /** + * 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 @@ -70,6 +81,9 @@ export class EnvGraphDataType { get isSensitive() { return this.def.sensitive; } get isInternal() { return this.def.internal; } get docsEntries() { return this.def.docs; } + generatePlaceholder(seed: string) { + return this.def.generatePlaceholder?.(seed); + } /** @internal */ get _rawDef() { return this.def; } @@ -134,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. */ @@ -321,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}`; @@ -417,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(); @@ -520,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; @@ -531,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 3d4794d05..fb8b95f2f 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -1,16 +1,20 @@ /// import _ from '@env-spec/utils/my-dash'; import { - ParsedEnvSpecFunctionCall, ParsedEnvSpecStaticValue, + ParsedEnvSpecFunctionArgs, ParsedEnvSpecFunctionCall, ParsedEnvSpecStaticValue, parseEnvSpecDotEnvFile, type ParsedEnvSpecDecorator, } 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, ObjectLiteralResolver, 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'; +import { parseDuration } from '../../lib/duration'; +import { PROXY_APPROVAL_EACH_VALUES } from '../../proxy/types'; export abstract class DecoratorInstance { @@ -54,6 +58,13 @@ export abstract class DecoratorInstance { get incompatibleWith() { return this.decoratorDef?.incompatibleWith; } + 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; @@ -106,37 +117,48 @@ 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 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(', ')}}` + : fallback; + throw new SchemaError( + `@${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( + `@${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 // 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, ); @@ -276,6 +298,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); @@ -283,6 +307,165 @@ 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. + */ +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; + +/** 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') { + 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`); + } +} + +/** + * `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}'); + } +} + +/** + * 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'); + } + + // 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'); + 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'); + } +} + // root decorators export const builtInRootDecorators: Array> = [ { @@ -361,6 +544,42 @@ export const builtInRootDecorators: Array> = [ { name: 'disableProcessEnvInjection', }, + { + // 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' && key !== 'reload') { + throw new SchemaError(`@proxyConfig: unknown option "${key}" (supported: egress, reload)`); + } + } + const egressResolver = decValue.objArgs.egress; + 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"'); + } + } + }, + }, + { + name: 'proxy', + isFunction: true, + useFnArgsResolver: true, + process: (argsVal) => validateProxyFunctionArgs(argsVal), + }, { name: 'auditIgnorePaths', isFunction: true, @@ -500,6 +719,16 @@ 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). + * 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; @@ -530,24 +759,58 @@ 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', }, + { + name: 'placeholder', + process: (decVal) => { + if (!decVal.isStatic || !_.isString(decVal.staticValue)) { + throw new SchemaError('@placeholder must be a static string value'); + } + }, + }, + { + name: 'proxy', + isFunction: true, + isFunctionOrValue: true, + useFnArgsResolver: true, + 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], [approval=true | approval={each, maxDuration, enabled}], [keys=[...]]) + validateProxyFunctionArgs(decVal); + }, + }, // 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..1c3d29fb8 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -14,7 +14,10 @@ import { ResolutionError, SchemaError } from './errors'; import { generateTypes } from './type-generation'; import { - builtInItemDecorators, builtInRootDecorators, RootDecoratorInstance, type ItemDecoratorDef, type RootDecoratorDef, + builtInItemDecorators, builtInRootDecorators, + RootDecoratorInstance, + type ItemDecoratorDef, + type RootDecoratorDef, } from './decorators'; import { getErrorLocation } from './error-location'; import type { VarlockPlugin } from './plugins'; @@ -22,7 +25,13 @@ 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, + type ProxyApprovalEach, type ProxyEgressMode, type ProxyManagedItem, type ProxyRule, +} from '../../proxy/types'; +import { parseDuration } from '../../lib/duration'; const processExists = !!globalThis.process; const originalProcessEnv = { ...processExists && process.env }; @@ -55,6 +64,9 @@ export type SerializedEnvGraph = { preventLeaks?: boolean; encryptInjectedEnv?: boolean; disableProcessEnvInjection?: boolean; + proxyEgress?: ProxyEgressMode; + /** `@proxyConfig={reload=...}` posture; the proxy resolves `auto` at launch. */ + proxyReload?: 'off' | 'manual' | 'auto'; }, config: Record; - /** 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; }; +/** + * 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 @@ -94,6 +115,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 */ @@ -549,6 +580,8 @@ export class EnvGraph { await this.getRootDec('preventLeaks')?.resolve(); await this.getRootDec('encryptInjectedEnv')?.resolve(); await this.getRootDec('disableProcessEnvInjection')?.resolve(); + await this.getRootDec('proxyConfig')?.resolve(); + await Promise.all(this.getRootDecFns('proxy').map(async (d) => d.resolve())); } get graphAdjacencyList() { @@ -742,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), ), @@ -753,6 +786,11 @@ 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 proxyConfig = this.getRootDec('proxyConfig')?.resolvedValue; + serializedGraph.settings.proxyEgress = proxyConfig?.egress === 'strict' ? 'strict' : 'permissive'; + // Store the raw reload posture (off/manual/auto); the proxy command resolves `auto` + // at launch from context. Absent = undefined, and the command defaults it to `auto`. + if (proxyConfig?.reload) serializedGraph.settings.proxyReload = proxyConfig.reload; // collect all errors into a single nested object const errors: SerializedEnvGraphErrors = {}; @@ -859,4 +897,204 @@ export class EnvGraph { /** plugins installed globally in the graph */ plugins: Array = []; + + /** + * 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); + } + + /** + * 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 { + // 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)}`); + } + if (obj?.path !== undefined && !_.isString(obj.path)) { + throw new SchemaError(`@proxy: path must resolve to a string, got ${JSON.stringify(obj.path)}`); + } + // `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)'); + } + } + } + + // `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); + } + } + } + + /** + * Approval fields for a rule, from a resolved `@proxy(...)` arg object. + * `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 approval = obj?.approval; + let required = false; + 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 + each = _.isString(approval.each) ? (approval.each as ProxyApprovalEach) : undefined; + maxDurationMs = approval.maxDuration !== undefined + ? parseDuration(approval.maxDuration as string | number) + : undefined; + } + + if (!required) return {}; + return { + approval: { + ...(each ? { each } : {}), + ...(maxDurationMs !== undefined ? { maxDurationMs } : {}), + }, + }; + } + + /** 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 = []; + + // 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 itemKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); + rules.push(...EnvGraph.expandProxyRules(resolved?.obj, domain, itemKeys)); + } + + // 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(); + EnvGraph.validateResolvedProxyObj(resolved?.obj); + const domain = EnvGraph.normalizeStringList(resolved?.obj?.domain); + if (domain.length === 0) continue; + const extraKeys = EnvGraph.normalizeStringList(resolved?.obj?.keys); + const itemKeys = _.uniq([itemKey, ...extraKeys]); + rules.push(...EnvGraph.expandProxyRules(resolved?.obj, domain, itemKeys)); + } + } + + 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, isGenericFallback } = await generateProxyPlaceholderForItem(item, usedPlaceholders); + managedItems.push({ + key, + placeholder, + realValue: item.resolvedValue, + ...(isGenericFallback ? { placeholderIsGenericFallback: true } : {}), + }); + } + + 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..8f59b479c --- /dev/null +++ b/packages/varlock/src/env-graph/test/proxy-mode.test.ts @@ -0,0 +1,356 @@ +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` + # @proxyConfig={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([ + { + domain: ['api.example.com'], + itemKeys: [], + }, + { + domain: ['api.stripe.com'], + itemKeys: ['STRIPE_KEY'], + }, + ]); + }); + + 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([ + { + 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` + # @proxyConfig={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([{ 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('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` + # @proxyConfig={egress="strict"} + # @proxy(domain="api.a.com") + # @proxy(domain="api.b.com", path="/admin/**", approval=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([ + { domain: ['api.a.com'] }, + { domain: ['api.b.com'], path: '/admin/**', approval: {} }, + ]); + }); + + test('approval object form: each + maxDuration parse onto the rule', async () => { + const graph = await loadGraph(outdent` + # @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}) + # --- + BASELINE=1 + `); + + expect(await graph.getProxyRules()).toMatchObject([ + { domain: ['api.a.com'], approval: {} }, + { + domain: ['api.b.com'], approval: { each: 'request', maxDurationMs: 900_000 }, + }, + { + domain: ['api.c.com'], approval: { each: 'host', maxDurationMs: 0 }, + }, + ]); + }); + + 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", 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(); + }); + + 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) => /unknown approval option "eech"/.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 + # --- + # @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` + # --- + BASELINE=1 + + # @proxy(domain="api.example.com") + # @placeholder=sk_test_explicit + EXPLICIT_KEY=sk_live_real_explicit + + # @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 honoring startsWith + isLength, while staying unique. + expect(byKey.EXPLICIT_KEY?.placeholder).toBe('sk_test_explicit'); + 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(); + + // 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.TYPE_KEY?.realValue).toBe('tok_real_secret'); + 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/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 2cd863bca..5845ec53d 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'; @@ -8,14 +8,17 @@ 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'; 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 { @@ -50,6 +53,7 @@ function loadFromPaths( overrideValues?: Record, clearCache?: boolean, skipCache?: boolean, + proxyResolutionView?: ProxyResolutionView, }, ) { const resolvedPaths = rawPaths.map((p) => path.resolve(p)); @@ -80,11 +84,14 @@ function loadFromPaths( afterInit: async (g) => { g.registerResolver(VarlockResolver); g.registerResolver(KeychainResolver); + if (config.proxyResolutionView) { + g.proxyResolutionView = config.proxyResolutionView; + } }, }))); } -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, @@ -92,51 +99,90 @@ export 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 reload` can apply a schema change without being blocked by the + * very guard it exists to clear. + */ + skipProxyFingerprintGuard?: boolean, }) { const runtimeOverrideValues = getGraphEnvOverridesFromRuntimeEnv(); - 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, + // 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.', }); } - // Fall back to package.json varlock.loadPath - const pkgLoadPath = readVarlockPackageJsonConfig()?.loadPath; - const pkgLoadPaths = pkgLoadPath ? normalizePkgLoadPath(pkgLoadPath) : undefined; + const proxyResolutionView = await getProxyResolutionViewForEnv().catch(() => undefined); + if (proxyResolutionView) { + debug('applying proxy resolution view (%d item(s))', Object.keys(proxyResolutionView).length); + } - 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.', + const cliPaths = opts?.entryFilePaths?.filter(Boolean); + + 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, + proxyResolutionView, + }); + } + + // 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, + proxyResolutionView, + }); + } + + debug('no path configured, using cwd: %s', process.cwd()); + + 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 (proxyResolutionView) { + g.proxyResolutionView = proxyResolutionView; + } + }, + }))); + })(); - 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); - }, - }))); + return graph; } 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: [], 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..98e58ba36 --- /dev/null +++ b/packages/varlock/src/lib/test/load-graph.test.ts @@ -0,0 +1,101 @@ +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 { getProxyResolutionViewForEnvMock, getActiveProxySessionMock } = vi.hoisted(() => ({ + getProxyResolutionViewForEnvMock: vi.fn(), + getActiveProxySessionMock: vi.fn(), +})); + +vi.mock('../../proxy/session-registry', () => ({ + 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'; +import { PROXY_CHILD_ENV_VAR } from '../../proxy/env-vars'; + +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')); + 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 resolution view exists', async () => { + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + expect(graph.getResolvedEnvObject().API_KEY).toBe('real-secret'); + }); + + test('applies a proxy placeholder directive when present', async () => { + getProxyResolutionViewForEnvMock.mockResolvedValue({ + API_KEY: { kind: 'placeholder', value: '<>' }, + }); + + const graph = await loadVarlockEnvGraph({ entryFilePaths: [tempDir] }); + await graph.resolveEnvValues(); + + 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(); + }); + + 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/approval-grants.test.ts b/packages/varlock/src/proxy/approval-grants.test.ts new file mode 100644 index 000000000..30252b1b4 --- /dev/null +++ b/packages/varlock/src/proxy/approval-grants.test.ts @@ -0,0 +1,171 @@ +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 ApprovalLifetime, type ApprovalProvider, +} from './approval'; + +// Redirect the grants dir into a throwaway XDG_CONFIG_HOME (the path 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 KEY = 'api.stripe.com approval POST api.stripe.com/v1/refunds'; + +function grant(overrides: Partial = {}) { + return { + 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 key', async () => { + const store = createApprovalGrantStore('sess-1'); + await store.add(grant()); + 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 key', async () => { + const store = createApprovalGrantStore('sess-1'); + await store.add(grant()); + 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({ kind: 'duration', expiresAt: Date.now() + 10_000 })); + expect(await store.findMatch(KEY)).toBeDefined(); + + const store2 = createApprovalGrantStore('sess-2'); + 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(KEY)).toBeUndefined(); + }); + + 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); + // 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$/); + }); +}); + +function req(opts: { maxDurationMs?: number; ruleId?: string } = {}) { + return createApprovalRequest({ + method: 'POST', + host: 'api.stripe.com', + path: '/v1/refunds', + body: 'x', + ruleId: opts.ruleId ?? 'api.stripe.com approval', + ...(opts.maxDurationMs !== undefined ? { maxDurationMs: opts.maxDurationMs } : {}), + }); +} + +function innerReturning(lifetime: ApprovalLifetime): { provider: ApprovalProvider; calls: () => number } { + let calls = 0; + return { + provider: { + async requestApproval(r) { + calls += 1; + return { approved: true, nonce: r.nonce, lifetime }; + }, + }, + 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 }); + + expect((await provider.requestApproval(req())).approved).toBe(true); + expect(inner.calls()).toBe(1); + expect(await store.list()).toHaveLength(1); + + const second = await provider.requestApproval(req()); + expect(second.approved).toBe(true); + expect(second.reason).toMatch(/auto-approved/); + expect(inner.calls()).toBe(1); // inner not consulted again + }); + + test('a once approval is not persisted (re-prompts)', async () => { + const store = createApprovalGrantStore('sess-1'); + const inner = innerReturning({ kind: 'once' }); + const provider = createGrantingApprovalProvider({ inner: inner.provider, store }); + await provider.requestApproval(req()); + await provider.requestApproval(req()); + 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 }; + }, + }; + const provider = createGrantingApprovalProvider({ inner, store }); + expect((await provider.requestApproval(req())).approved).toBe(false); + expect(await store.list()).toHaveLength(0); + }); + + test('maxDuration clamps a session approval to a bounded duration grant', async () => { + const store = createApprovalGrantStore('sess-1'); + 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.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 new file mode 100644 index 000000000..157e3b5a8 --- /dev/null +++ b/packages/varlock/src/proxy/approval-grants.ts @@ -0,0 +1,147 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +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 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; + /** 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; + kind: 'session' | 'duration'; + /** Epoch ms; required for `duration` kind. */ + expiresAt?: number; + grantedAt: string; + /** Provenance — `tty` now, `phone`/`app` later. */ + grantedBy: string; +}; + +export function getGrantFilePath(sessionUuid: string): string { + return join(getProxySessionDir(sessionUuid), 'grants.jsonl'); +} + +function isGrantValid(grant: ApprovalGrant, now: number): boolean { + if (grant.kind === 'session') return true; + if (grant.kind === '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(), + grantKey: grant.grantKey, + ruleId: grant.ruleId, + host: grant.host, + kind: grant.kind, + grantedBy: grant.grantedBy, + ...(grant.expiresAt !== undefined ? { expiresAt: grant.expiresAt } : {}), + }; + try { + await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(full)}\n`, { mode: 0o600 }); + } catch { + // best-effort + } + return full; + }, + /** The first still-valid grant for `grantKey`, or undefined. */ + async findMatch(grantKey: string): Promise { + const now = Date.now(); + return (await list()).find((g) => g.grantKey === grantKey && isGrantValid(g, now)); + }, + }; +} + +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 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; + store: ApprovalGrantStore; + grantedBy?: string; +}): ApprovalProvider { + return { + async requestApproval(req) { + // 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, + 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 && 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 new file mode 100644 index 000000000..aa39681e5 --- /dev/null +++ b/packages/varlock/src/proxy/approval.test.ts @@ -0,0 +1,180 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, test } from 'vitest'; + +import { + clampLifetime, + 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('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(); + 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(decision.lifetime).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).lifetime).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.lifetime).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(); + 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('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); + }); + + 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); + }); + + 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 new file mode 100644 index 000000000..e2d523b89 --- /dev/null +++ b/packages/varlock/src/proxy/approval.ts @@ -0,0 +1,270 @@ +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 — + * 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; + /** + * 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 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 ApprovalLifetime = | { 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`. */ + lifetime?: ApprovalLifetime; + 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; + +/** + * 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; + path: string; + 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, + 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 } : {}), + }; +} + +/** + * 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; +} + +/** + * 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 { + async requestApproval(req) { + return { approved: false, nonce: req.nonce, reason: 'no approver available (auto-deny)' }; + }, + }; +} + +/** 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; + +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, lifetime: { kind: 'once' } }; + } + if (a === 's' && opts.allowSession) { + return { approved: true, nonce, lifetime: { kind: 'session' } }; + } + if (a === 'm' && opts.durationMs !== undefined) { + return { approved: true, nonce, lifetime: { kind: 'duration', durationMs: opts.durationMs } }; + } + 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. 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 }; + 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' }; + } + + // 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? ${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'); + resolve(''); + }, 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(''); + }); + }); + rl.close(); + + return parseTtyAnswer(answer, req.nonce, { allowSession, durationMs }); + }, + }; +} diff --git a/packages/varlock/src/proxy/audit.test.ts b/packages/varlock/src/proxy/audit.test.ts new file mode 100644 index 000000000..1f37469e9 --- /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. The path 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: [{ 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..44b46f685 --- /dev/null +++ b/packages/varlock/src/proxy/audit.ts @@ -0,0 +1,155 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +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) + | '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 + | '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 + * 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. Co-located in the +// session's directory so a session's audit travels with its record. +export function getProxyAuditFilePath(uuid: string): string { + return join(getProxySessionDir(uuid), 'audit.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(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 + } + }); + }; + + 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/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/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/placeholder.ts b/packages/varlock/src/proxy/placeholder.ts new file mode 100644 index 000000000..a8a0c9e3d --- /dev/null +++ b/packages/varlock/src/proxy/placeholder.ts @@ -0,0 +1,126 @@ +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); +} + +/** + * 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 endsWith = _.isString(simplifiedArgs.endsWith) ? simplifiedArgs.endsWith : ''; + const isLength = _.isNumber(simplifiedArgs.isLength) ? simplifiedArgs.isLength : undefined; + + if (!startsWith && !endsWith && isLength === undefined) return undefined; + + if (isLength !== undefined) { + if (isLength <= 0) return ''; + 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}${seed}${endsWith}`; +} + +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 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(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) + * + * 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, + usedPlaceholders: Set, +): Promise { + const placeholderDec = item.getDec('placeholder'); + if (placeholderDec) { + const explicitPlaceholder = await placeholderDec.resolve(); + if (_.isString(explicitPlaceholder) && explicitPlaceholder.length > 0) { + return { placeholder: ensureUnique(explicitPlaceholder, usedPlaceholders), isGenericFallback: false }; + } + } + + 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, seed); + if (fromType) { + return { placeholder: ensureUnique(fromType, usedPlaceholders), isGenericFallback: false }; + } + + return { placeholder: ensureUnique(buildFallbackPlaceholder(item.key), usedPlaceholders), isGenericFallback: true }; +} diff --git a/packages/varlock/src/proxy/policy.test.ts b/packages/varlock/src/proxy/policy.test.ts new file mode 100644 index 000000000..a0f455b8e --- /dev/null +++ b/packages/varlock/src/proxy/policy.test.ts @@ -0,0 +1,107 @@ +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 => ({ + 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; 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'] }), 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'); + }); + + test('an approve rule yields require-approval', () => { + 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'); + }); + + test('block beats require-approval beats allow', () => { + const layered = [ + rule({ domain: ['api.x.com'] }), // broad allow + 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'); + 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'], 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'); + 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/*', approval: {} }), + ]; + expect(evaluateProxyPolicy(facts('api.x.com', 'GET', '/v1/x'), tied).verdict).toBe('require-approval'); + }); +}); + +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..981b13f62 --- /dev/null +++ b/packages/varlock/src/proxy/policy.ts @@ -0,0 +1,142 @@ +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; +}; + +export function normalizeHost(host: string): string { + return host.toLowerCase().trim(); +} + +export 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(ruleMethods: Array, method: string): boolean { + const allowed = ruleMethods.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); +} + +/** + * 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.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'); + 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 `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 + * `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 approval. + */ +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 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 approvalRule = topTier.find((rule) => rule.approval); + if (approvalRule) { + return { + verdict: 'require-approval', + matchedRule: approvalRule, + reason: 'requires approval per @proxy(approval=...) rule', + }; + } + return { verdict: 'allow', matchedRule: topTier[0] }; +} + +/** + * 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/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..cda0b4801 --- /dev/null +++ b/packages/varlock/src/proxy/proxy-tls.test.ts @@ -0,0 +1,416 @@ +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; +} + +// 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 = ''; + 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 activities: Array = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'sk-stub-PLACEHOLDER', realValue: 'sk-stub-REALKEY' }], + rules: [{ domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }], + egressMode: 'permissive', + onActivity: (a) => activities.push(a), + }); + 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'); + + // 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(); + }); + + 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: [{ 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(); + }); + + 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: [{ 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 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'); + + 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); + + // ...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(); + }); + + 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: [{ 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('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: [ + { domain: [UPSTREAM_HOST], itemKeys: ['API_KEY'] }, + { + 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) => { + 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: [ + { domain: [UPSTREAM_HOST], itemKeys: ['ITEM_A'] }, + { 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/reload-channel.test.ts b/packages/varlock/src/proxy/reload-channel.test.ts new file mode 100644 index 000000000..227e15105 --- /dev/null +++ b/packages/varlock/src/proxy/reload-channel.test.ts @@ -0,0 +1,79 @@ +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('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(); + }); + + 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..dc2d4cc6b --- /dev/null +++ b/packages/varlock/src/proxy/reload-channel.ts @@ -0,0 +1,96 @@ +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 reload` and the + * process that owns a live proxy runtime (a `proxy start` daemon or a + * 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 + * 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; + /** + * 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; +}; + +/** All terminal. `denied` = the owner refused it (today: requested from inside the agent). */ +export type ProxyReloadStatus = 'done' | 'error' | 'denied'; + +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)); +} 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..4887bae7c --- /dev/null +++ b/packages/varlock/src/proxy/runtime-proxy.test.ts @@ -0,0 +1,580 @@ +import { describe, expect, test } from 'vitest'; +import http from 'node:http'; +import { URL } from 'node:url'; + +import type { ProxyActivity } from './audit'; +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); + return await new Promise<{ + statusCode: number; + body: string; + headers: http.IncomingHttpHeaders; + }>((resolve, reject) => { + const req = http.request({ + host: proxy.hostname, + port: Number(proxy.port), + method: 'GET', + path: targetUrl, + headers, + }, (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'), + headers: res.headers, + }); + }); + }); + 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(); + }); + + 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: [{ 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 = ''; + const upstream = http.createServer((req, res) => { + upstreamGotRequest = true; + upstreamAuth = String(req.headers.authorization ?? ''); + 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 runtime = await startLocalProxyRuntime({ + managedItems: [{ key: 'API_KEY', placeholder: 'PH_placeholder', realValue: 'sk-REAL-secret' }], + rules: [{ 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}/`, { + authorization: 'Bearer PH_placeholder', + }); + + // 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) => { + 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({ + // No injected items — these tests exercise forwarding/streaming behavior, + // not injection (which now requires TLS, see proxy-tls.test.ts). + managedItems: [], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], + 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('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: [ + { + 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: [{ 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: [{ 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('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: [ + { + domain: ['127.0.0.1'], itemKeys: [], approval: {}, + }, + ], + 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: [ + { + domain: ['127.0.0.1'], itemKeys: [], approval: {}, + }, + ], + 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) => { + 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({ + // No injected items — these tests exercise forwarding/streaming behavior, + // not injection (which now requires TLS, see proxy-tls.test.ts). + managedItems: [], + rules: [{ domain: ['127.0.0.1'], itemKeys: [] }], + 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()); + }); + }); +}); + +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 authFailures: Array = []; + const served: Array<{ passthroughCount?: number } | undefined> = []; + const runtime = await startLocalProxyRuntime({ + managedItems: [], + rules: [], + egressMode: opts?.egressMode ?? 'permissive', + internalEndpoint: { + token: TOKEN, + onAuthFailure: () => authFailures.push(true), + onServed: (meta) => served.push(meta), + }, + onActivity: (a) => activities.push(a), + }); + 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, 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, + }); + 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([]); + // owner visibility: served callback fires with the payload's meta, no auth failures + expect(served).toEqual([{ passthroughCount: 2 }]); + expect(authFailures).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, 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', + }); + 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); + // both attempts surfaced to the owner; nothing served + expect(authFailures).toEqual([true, true]); + expect(served).toEqual([]); + 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 new file mode 100644 index 000000000..523b57c3f --- /dev/null +++ b/packages/varlock/src/proxy/runtime-proxy.ts @@ -0,0 +1,1052 @@ +import { timingSafeEqual } from 'node:crypto'; +import { + mkdtemp, 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 { Transform } from 'node:stream'; +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 { + describeRule, domainMatches, evaluateProxyPolicy, getRequestScopedManagedItems, normalizeHost, 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'; + +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 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. + */ + reconfigure: (next: ProxyReconfigureInput) => void; + /** + * Set/replace the encoded session-env payload the `varlock.internal` endpoint + * serves (see `internalEndpoint`). Called once after startup and again on + * every reload so attach fetches are always current. + */ + setSessionEnvPayloadJson: (payloadJson: string, meta?: SessionEnvPayloadMeta) => void; + 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. + */ + 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 + * 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. + */ + 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 }; + +type HeaderTransformFn = (value: string) => string; + +function parseHostPort(value: string): HostInfo | null { + // 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 hostMatchesProxyRules(host: string, rules: Array): boolean { + return rules.some((rule) => rule.domain.some((d) => domainMatches(d, host))); +} + +/** + * Invariant #1: bind secret injection to the *verified upstream TLS identity*, + * 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 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 }); + }); + }); +} + +/** + * 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; + each?: ProxyApprovalEach; + maxDurationMs?: number; + 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, + each: input.each, + maxDurationMs: input.maxDurationMs, + injectedKeys: input.injectedKeys, + }); + try { + const decision = await input.approvalProvider.requestApproval(request); + return isApprovalValid(request, decision); + } catch { + return false; + } +} + +export function replacePlaceholdersWithReal(value: string, managedItems: Array): string { + let next = value; + // 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; +} + +/** + * Which managed items' placeholders actually appear in this request — i.e. the + * secrets that will really be injected. Used for the audit log so it records + * what was injected (keys only), not merely what was in scope. + */ +function detectInjectedKeys(parts: 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] + .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; +} + +/** + * Fail-closed response for a blocked/failed request. When `teardown` is set (the + * MITM tunnel path), short status-only responses don't reliably flush through the + * CONNECT tunnel, so we write a best-effort response and destroy the socket. The + * absolute-form (plain http) path ends the response normally. + */ +function respondBlocked( + res: http.ServerResponse, + code: number, + message: string, + teardown: boolean, +): void { + if (!res.headersSent) { + try { + if (teardown) { + res.writeHead(code, { 'content-type': 'text/plain', connection: 'close' }); + } else { + res.statusCode = code; + } + res.end(message); + } catch { /* response may already be gone */ } + } else { + try { + res.end(); + } catch { /* ignore */ } + } + 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); +} + +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. */ +type ProxiedRequestTransport = { + host: string; + port: number; + isHttps: boolean; + method: string; + /** Path component for policy facts/activity (no query). */ + pathOnly: string; + /** Origin-form path+query sent upstream (and scrubbed) — also used as the activity URL. */ + requestTarget: string; + /** When set, override the upstream `Host` header (absolute-form). Undefined = pass the client's through (MITM). */ + upstreamHostHeader?: string; + /** Deny/approval/error responses tear the socket down (MITM tunnel) rather than ending normally. */ + tunnelTeardown: boolean; +}; + +function transformHeaders( + headers: http.IncomingHttpHeaders, + 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) => transformValue(v)); + } else { + 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'); +} + +// 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) + && !isStreamingResponse(headers) + && isBoundedRedactableBody(headers); +} + +function redactOutgoingHeaders( + headers: http.IncomingHttpHeaders, + managedItems: Array, +): Record> { + return transformHeaders( + headers, + (value) => replaceRealWithPlaceholders(value, managedItems), + ); +} + +/** 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)); +} + +/** 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 + * 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, + 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 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) { + const decoded = carry + decoder.end(); + note(decoded); + cb(null, Buffer.from(replaceRealWithPlaceholders(decoded, managedItems), 'utf8')); + }, + }); +} + +function forwardUpstreamResponseWithRedaction( + upstreamRes: http.IncomingMessage, + 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 }; + + 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); + if (canScrubStream) { + 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; + } + + 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 bodyKeys = detectScrubbedKeys(originalBody, managedItems); + 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 }; + headersForWrite['content-length'] = String(redactedBuffer.byteLength); + delete headersForWrite['transfer-encoding']; + delete headersForWrite.etag; + + clientRes.writeHead(statusCode, headersForWrite); + clientRes.end(redactedBuffer); + report([...headerKeys, ...bodyKeys], false); + }); + + upstreamRes.on('error', () => { + if (!clientRes.headersSent) clientRes.statusCode = 502; + clientRes.end('Upstream proxy error'); + }); +} + +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); +} + +/** + * Local MITM proxy runtime for `varlock proxy run`. + * Rewrites placeholder values to real values for requests matching @proxy domains. + */ +export async function startLocalProxyRuntime({ + managedItems: initialManagedItems, + rules: initialRules, + egressMode: initialEgressMode, + onActivity, + onResponse, + approvalProvider, + 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 + // changes behavior on the next request (in-flight requests already snapshotted). + let managedItems = initialManagedItems; + let rules = initialRules; + 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-')); + 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'); + + // 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, + ) => { + // 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; + } + 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); + internalEndpoint.onServed?.(sessionEnvPayloadMeta); + } 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 + // failure path fails closed via respondBlocked. + const processProxiedRequest = async ( + req: http.IncomingMessage, + 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, + }; + + const shouldRewrite = hostMatchesProxyRules(t.host, rules); + const shouldAllowEgress = egressMode === 'permissive' || shouldRewrite; + if (!shouldAllowEgress) { + onActivity?.({ + ...baseActivity, matched: shouldRewrite, blocked: true, decision: 'blocked-egress', + }); + 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; + } + + // 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 } : {}; + if (policyDecision?.verdict === 'deny') { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'deny', + }); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: a @proxy block rule denies this request to ${t.host}.`, 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, `Blocked by the varlock credential proxy: refusing to inject a secret into a cleartext (non-TLS) connection to ${t.host}.`, false); + return; + } + + const body = await readBody(req); + const injectedKeys = shouldRewrite + ? 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, + // request-bound decision. Fail closed (deny) unless explicitly approved. + if (policyDecision?.verdict === 'require-approval') { + const approved = await runApprovalGate({ + approvalProvider, + method: t.method, + host: t.host, + path: t.pathOnly, + body, + ruleId: ruleIdStr, + each: policyDecision.matchedRule?.approval?.each, + maxDurationMs: policyDecision.matchedRule?.approval?.maxDurationMs, + injectedKeys, + }); + if (!approved) { + onActivity?.({ + ...baseActivity, ...ruleId, matched: true, blocked: true, decision: 'approval-denied', + }); + respondBlocked(res, 403, `Blocked by the varlock credential proxy: this request to ${t.host} required approval and it was not granted.`, t.tunnelTeardown); + 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 + ? replacePlaceholdersWithReal(t.requestTarget, hostItems) + : t.requestTarget; + + const upstreamHeaders = transformHeaders( + req.headers, + shouldRewrite + ? (value) => replacePlaceholdersWithReal(value, hostItems) + : (value) => value, + ); + delete upstreamHeaders['proxy-connection']; + delete upstreamHeaders.connection; + if (t.upstreamHostHeader !== undefined) upstreamHeaders.host = t.upstreamHostHeader; + if (rewrittenBody.byteLength !== body.byteLength) { + 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:', + // 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 + ? { + ...(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, + method: t.method, + path: t.pathOnly, + onResponse, + }); + }); + + upstreamReq.on('error', () => { + // 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); + const cached = hostMitmServers.get(normalized); + if (cached) return cached; + + const hostCert = await createHostCert(ca, normalized); + const server = https.createServer({ + key: hostCert.keyPem, + cert: hostCert.certPem, + 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 isHttps = destination.protocol === 'https:'; + 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, + }); + }); + + 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; + } + + // 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) { + // 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 = `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}`, + ); + 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, + }, + setSessionEnvPayloadJson: (payloadJson, meta) => { + sessionEnvPayloadJson = payloadJson; + sessionEnvPayloadMeta = meta; + }, + reconfigure: (next) => { + managedItems = next.managedItems; + rules = next.rules; + egressMode = next.egressMode; + }, + 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/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, + }; +} 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..1419dd031 --- /dev/null +++ b/packages/varlock/src/proxy/session-registry.test.ts @@ -0,0 +1,100 @@ +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('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(); + 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 new file mode 100644 index 000000000..b923984e6 --- /dev/null +++ b/packages/varlock/src/proxy/session-registry.ts @@ -0,0 +1,484 @@ +import crypto from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { + mkdir, readdir, readFile, rename, writeFile, +} from 'node:fs/promises'; +import { dirname, 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, + 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; + /** 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 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. + * 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; + /** + * 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 + * (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; + command?: Array; +}; + +export type ProxySessionStats = { + totalRequests: number; + matchedRequests: number; + blockedRequests: number; + lastActivityAt?: string; +}; + +// 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(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(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(); +} + +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) { + out += alphabet[crypto.randomInt(alphabet.length)]; + } + return out; +} + +function parseSessionRecord(raw: 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), + ...(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.endpointToken ? { endpointToken: String(parsed.endpointToken) } : {}), + ...(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)]), + ), + } + : {}), + ...(Array.isArray(parsed.omittedKeys) + ? { omittedKeys: parsed.omittedKeys.map((v) => 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 { + // 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; + } +} + +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'; + } +} + +/** + * 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?: { + includeEnded?: boolean; +}): Promise> { + await ensureSessionsDir(); + + const sessionsDir = getSessionsDir(); + const dirEntries = await readdir(sessionsDir, { withFileTypes: true }); + const sessions: Array = []; + + 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); + if (!parsed) continue; + + if (!opts?.includeEnded) { + if (parsed.endedAt) continue; + if (!isProcessRunning(parsed.ownerPid)) continue; + } + + sessions.push(parsed); + } + + 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); + if (!existing || existing.endedAt) return; + const ts = nowIso(); + const next: ProxySessionRecord = { ...existing, endedAt: ts, updatedAt: ts }; + await writeSessionRecordAtomic(filePath, next); +} + +/** + * 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() { + 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 }> { + 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 mkdir(getProxySessionDir(session.uuid), { recursive: true, mode: 0o700 }); + const next: ProxySessionRecord = { + ...session, + updatedAt: nowIso(), + }; + await writeSessionRecordAtomic(getSessionFilePath(next.uuid), next); + 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; +} + +/** + * Resolve the proxy session the current process is running under, if any. + * + * Tries the explicit env-injected session token first (fast path), then falls + * back to **process ancestry** — matching a running session's ownerPid/childPid + * against this process's parent chain. The ancestry check is what makes the + * guards robust: a child can't escape by clearing `__VARLOCK_PROXY_CHILD`, + * because its place in the proxy's process tree is not something it controls. + * + * Returns undefined cheaply when there are no active sessions (the common case), + * so the ancestry walk only runs when a proxy session actually exists. + */ +export async function resolveActiveProxySession( + env: NodeJS.ProcessEnv = process.env, +): Promise { + // Fast exit for the common case (proxy never used): don't create or read the + // sessions dir on every varlock invocation. + if (!existsSync(getSessionsDir())) 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) { + 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)) + || (s.attachedPids?.some((pid) => ancestors.has(pid)) ?? false), + ); +} + +/** + * 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( + uuid: string, + patch: Partial>, +): Promise { + const existing = await getProxySessionByToken(uuid); + if (!existing) return undefined; + const next: ProxySessionRecord = { + ...existing, + ...patch, + updatedAt: nowIso(), + }; + await writeSessionRecordAtomic(getSessionFilePath(existing.uuid), next); + 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; + 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; +} diff --git a/packages/varlock/src/proxy/types.ts b/packages/varlock/src/proxy/types.ts new file mode 100644 index 000000000..d5ed0ae1e --- /dev/null +++ b/packages/varlock/src/proxy/types.ts @@ -0,0 +1,41 @@ +export type ProxyEgressMode = 'permissive' | 'strict'; + +/** + * 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 = { + domain: Array; + itemKeys: Array; + path?: string; + /** Allowed HTTP methods (uppercased). Omitted = any method. */ + method?: Array; + block?: boolean; + /** + * 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. + */ + 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 = { + key: string; + placeholder: string; + realValue: string; + /** True when the placeholder is the generic format-agnostic fallback (may fail SDK key-format checks). */ + placeholderIsGenericFallback?: boolean; +};