|
| 1 | +--- |
| 2 | +title: Modal |
| 3 | +description: Using varlock with Modal sandboxes, from resolving and validating sandbox env vars to running the credential proxy so agent sandboxes only hold placeholders. |
| 4 | +--- |
| 5 | + |
| 6 | +[Modal](https://modal.com) runs code in gVisor-isolated cloud sandboxes, commonly as the execution layer for AI agents. It already has a secret store: [`modal.Secret`](https://modal.com/docs/guide/secrets) holds a dictionary of environment variables and injects them through `secrets=[...]` on `Sandbox.create()` and on each `exec`. That is the right tool for workloads you trust with the credentials they use. |
| 7 | + |
| 8 | +For agentic workloads it leaves a gap, because the agent ends up holding the real key in `os.environ`. The recommended shape there is the [broker sandbox](#credential-proxy-the-broker-sandbox): one sandbox runs the [credential proxy](/guides/proxy/) and holds the real secrets, and agent sandboxes route through it holding only [placeholders](/guides/proxy/rules/#placeholders). |
| 9 | + |
| 10 | +Modal is a good fit for this because both seams varlock needs are first-class: env injection at sandbox creation, and an egress allowlist that can pin an agent to a single host and be tightened while the sandbox is running. |
| 11 | + |
| 12 | +## Passing resolved values |
| 13 | + |
| 14 | +Run the orchestrator under [`varlock run`](/reference/cli/load-and-run/) (or import [`varlock/auto-load`](/integrations/javascript/) if it is Node): varlock resolves your values (plugins, `.env.local`, etc.), validates them against your schema, and redacts them in the orchestrator's logs. Then hand the sandbox a scoped subset. |
| 15 | + |
| 16 | +```python title="orchestrate.py" |
| 17 | +import subprocess |
| 18 | +import modal |
| 19 | + |
| 20 | +# one blob with the resolved env, scoped by --filter to what this sandbox |
| 21 | +# should see |
| 22 | +env_blob = subprocess.run( |
| 23 | + ["varlock", "load", "--format", "json-full", "--compact", "--filter", "STRIPE_*,SENTRY_DSN"], |
| 24 | + capture_output=True, text=True, check=True, |
| 25 | +).stdout.strip() |
| 26 | + |
| 27 | +app = modal.App.lookup("my-agents", create_if_missing=True) |
| 28 | +sb = modal.Sandbox.create( |
| 29 | + "sleep", "infinity", |
| 30 | + app=app, |
| 31 | + secrets=[modal.Secret.from_dict({ |
| 32 | + # a Node app that imports varlock hydrates process.env, the ENV object, |
| 33 | + # and log redaction from the blob - no .env files or CLI in the sandbox |
| 34 | + "__VARLOCK_ENV": env_blob, |
| 35 | + "_VARLOCK_USE_INJECTED_ENV": "1", |
| 36 | + # for workloads that don't import varlock, enumerate plain vars instead |
| 37 | + })], |
| 38 | +) |
| 39 | +``` |
| 40 | + |
| 41 | +This is the standard Modal posture: sandboxes hold real values, and the values transit Modal's control plane. Consuming the blob needs either the `varlock` npm package (Node 22.3+) or the varlock CLI (`varlock run -- <command>` injects plain env vars from the blob, for any workload). See [`_VARLOCK_USE_INJECTED_ENV`](/reference/reserved-variables/#_varlock_use_injected_env). |
| 42 | + |
| 43 | +## Credential proxy: the broker sandbox |
| 44 | + |
| 45 | +One long-lived sandbox (the broker) runs `varlock proxy start --expose`, which serves the built-in WebSocket tunnel on its proxy port. Modal's `encrypted_ports` tunnel carries it. Agent sandboxes reach it with `varlock proxy run --url`, which self-wires their placeholder env and CA certs from the broker over the tunnel. The proxy injects real values into requests at the wire, on verified TLS connections to hosts your schema allows, with every request checked against your [`@proxy` rules](/guides/proxy/rules/#routing-rules) and recorded in the [audit log](/guides/proxy/running/#auditing). A compromised or prompt-injected agent can exfiltrate nothing but placeholders. |
| 46 | + |
| 47 | +``` |
| 48 | +[agent sandbox] [broker sandbox] |
| 49 | + varlock proxy run --url ── wss ──▶ proxy :8080 + tunnel |
| 50 | + (egress pinned to the broker) (encrypted_ports tunnel URL) |
| 51 | +``` |
| 52 | + |
| 53 | +:::caution[Modal client version] |
| 54 | +The egress controls below need **modal 1.5 or newer**, which requires **Python 3.10+**. Installing under an older Python (macOS still ships 3.9 as `python3`) silently resolves to modal 1.2.x, which has only `block_network` and a legacy `cidr_allowlist`: no domain allowlist and no live policy update. Check with `python -c "import modal; print(modal.__version__)"`. |
| 55 | + |
| 56 | +Use the `sb.filesystem.*` calls for sandbox files (`make_directory`, `write_text`, `read_text`). The older top-level `sb.mkdir()` / `sb.open()` are deprecated and, per Modal's [filesystem migration guide](https://modal.com/docs/guide/migrate-sandbox-filesystem), are removed in 1.6.0. Some workspaces already refuse them server-side before then (`ConflictError: The legacy Sandbox filesystem API is no longer supported`, seen on 1.5.4), so migrate rather than relying on the version boundary. Note the argument order in the new API: `write_text(contents, remote_path)`. |
| 57 | +::: |
| 58 | + |
| 59 | +### Schema setup |
| 60 | + |
| 61 | +Mark the secrets your agents use with [`@proxy(domain=...)`](/reference/item-decorators/#proxy) and give each one an explicit [`@placeholder`](/reference/item-decorators/#placeholder): |
| 62 | + |
| 63 | +```env-spec title=".env.schema" |
| 64 | +# @proxy(domain="api.anthropic.com") |
| 65 | +# @placeholder=sk-ant-api03-000000000000000000000000 |
| 66 | +ANTHROPIC_API_KEY= |
| 67 | +
|
| 68 | +# @proxy(domain="api.stripe.com") |
| 69 | +# @placeholder=sk_test_00000000000000000000000000 |
| 70 | +STRIPE_SECRET_KEY= |
| 71 | +``` |
| 72 | + |
| 73 | +An explicit `@placeholder` is optional (the sandbox pulls whatever the schema produces from the broker), but worth setting when an SDK checks the key format client-side: a realistic-looking placeholder passes that check where a generic `vlk_placeholder_…` would not. |
| 74 | + |
| 75 | +Egress is permissive by default: proxied requests to hosts without a rule pass through untouched, which is usually fine because agents hold only placeholders. If the broker should refuse anything that does not match a rule, set [`@proxyConfig={egress="strict"}`](/guides/proxy/rules/#egress-modes) in the schema header. |
| 76 | + |
| 77 | +### Start the broker |
| 78 | + |
| 79 | +```python title="orchestrate.py" |
| 80 | +import os, secrets, urllib.parse |
| 81 | +import modal |
| 82 | + |
| 83 | +PROXY_PORT = 8080 |
| 84 | +VARLOCK = "/root/.config/varlock/bin/varlock" |
| 85 | +PROJ = "/root/proj" |
| 86 | + |
| 87 | +# One data-plane token, shared by the broker and every agent; generate it |
| 88 | +# yourself (or let the broker mint one and read it back with `varlock proxy |
| 89 | +# token`). It is the credential to USE the broker over the tunnel, not to read |
| 90 | +# its secrets. |
| 91 | +PROXY_TOKEN = secrets.token_hex(16) |
| 92 | + |
| 93 | +app = modal.App.lookup("my-agents", create_if_missing=True) |
| 94 | +image = modal.Image.debian_slim().apt_install("curl", "ca-certificates") |
| 95 | + |
| 96 | +broker = modal.Sandbox.create( |
| 97 | + "sleep", "infinity", |
| 98 | + app=app, image=image, workdir=PROJ, |
| 99 | + encrypted_ports=[PROXY_PORT], |
| 100 | + timeout=60 * 60, |
| 101 | +) |
| 102 | + |
| 103 | +broker.exec("bash", "-lc", |
| 104 | + f"mkdir -p {PROJ} && curl -sSfL https://varlock.dev/install.sh | sh -s").wait() |
| 105 | + |
| 106 | +# upload the schema (plus any other .env files your project loads); |
| 107 | +# real values arrive via secrets below instead |
| 108 | +with open(".env.schema") as f: |
| 109 | + broker.filesystem.write_text(f.read(), f"{PROJ}/.env.schema") |
| 110 | + |
| 111 | +# Schema keys resolve from the process env, so this Secret carries the |
| 112 | +# bootstrap: usually just your plugin's secret-zero (shown: a 1Password |
| 113 | +# service account). |
| 114 | +broker_secrets = [modal.Secret.from_dict({ |
| 115 | + "VARLOCK_PROXY_TOKEN": PROXY_TOKEN, |
| 116 | + "OP_SERVICE_ACCOUNT_TOKEN": os.environ["OP_SERVICE_ACCOUNT_TOKEN"], |
| 117 | +})] |
| 118 | + |
| 119 | +# start the proxy bound off-loopback so the tunnel is reachable. --persist-ca |
| 120 | +# reuses the CA across broker restarts, so agents that already trust it keep |
| 121 | +# working. --allow-reload lets you apply schema edits later without a restart; |
| 122 | +# the reload channel is only reachable from inside the broker, not by agents. |
| 123 | +broker.exec("bash", "-lc", |
| 124 | + f"cd {PROJ} && setsid nohup {VARLOCK} proxy start --expose --port {PROXY_PORT} " |
| 125 | + f"--cert-dir {PROJ}/.varlock-ca --persist-ca --allow-reload " |
| 126 | + f"> /root/proxy.log 2>&1 < /dev/null & echo launched", |
| 127 | + secrets=broker_secrets, |
| 128 | +).wait() |
| 129 | + |
| 130 | +# ready once the port answers (a bare GET returns 400, which is fine; we only |
| 131 | +# need the listener up, so no -f) |
| 132 | +broker.exec("bash", "-lc", |
| 133 | + f"until curl -s -o /dev/null --proxy '' http://127.0.0.1:{PROXY_PORT}; do sleep 0.3; done", |
| 134 | +).wait() |
| 135 | + |
| 136 | +broker_url = broker.tunnels()[PROXY_PORT].url |
| 137 | +broker_host = urllib.parse.urlparse(broker_url).hostname |
| 138 | +``` |
| 139 | + |
| 140 | +The `modal.Secret` here carries whatever bootstraps your schema. With secrets resolved from a manager via a [plugin](/guides/plugins/) (the usual setup), that is one service-account token, the secret zero, and the schema resolves everything else inside the broker. If some values exist only on your side, enumerate them instead (`"ANTHROPIC_API_KEY": ...`), so they are the orchestrator's own resolved values passing through. Either way agent sandboxes hold no real secrets at all. |
| 141 | + |
| 142 | +### Start agent sandboxes |
| 143 | + |
| 144 | +An agent needs nothing but varlock and `proxy run --url`. It pulls its placeholder env and CA certs from the broker over the tunnel, so there is no env or cert plumbing to pass. |
| 145 | + |
| 146 | +Create it **pre-armed**: both allowlists have to be initialized at creation to stay updatable later, and the agent needs open egress briefly to install varlock. |
| 147 | + |
| 148 | +```python title="orchestrate.py (continued)" |
| 149 | +agent = modal.Sandbox.create( |
| 150 | + "sleep", "infinity", |
| 151 | + app=app, image=image, workdir=PROJ, |
| 152 | + timeout=60 * 60, |
| 153 | + # pre-armed so the policy can be tightened while it runs |
| 154 | + outbound_domain_allowlist=["*"], |
| 155 | + outbound_cidr_allowlist=["0.0.0.0/0"], |
| 156 | +) |
| 157 | + |
| 158 | +agent.exec("bash", "-lc", |
| 159 | + f"mkdir -p {PROJ} && curl -sSfL https://varlock.dev/install.sh | sh -s").wait() |
| 160 | + |
| 161 | +# now clamp: the agent may reach the broker tunnel and nothing else |
| 162 | +agent._experimental_set_outbound_network_policy( |
| 163 | + outbound_domain_allowlist=[broker_host], |
| 164 | + outbound_cidr_allowlist=[], |
| 165 | +) |
| 166 | + |
| 167 | +# the token rides a Secret rather than the command line, so it stays out of |
| 168 | +# process listings |
| 169 | +agent.exec("bash", "-lc", |
| 170 | + f"cd {PROJ} && {VARLOCK} proxy run --url wss://{broker_host} -- your-agent-command", |
| 171 | + secrets=[modal.Secret.from_dict({"VARLOCK_PROXY_TOKEN": PROXY_TOKEN})], |
| 172 | +).wait() |
| 173 | +``` |
| 174 | + |
| 175 | +To see what agents are doing, run [`varlock proxy audit`](/reference/cli/proxy/) (or `proxy status --watch`) inside the broker: every request records its host, path, decision, and which keys were injected. |
| 176 | + |
| 177 | +### Lock down agent egress |
| 178 | + |
| 179 | +The clamp above is what turns "the agent holds placeholders" into "the agent cannot talk to anything except varlock policy". Modal enforces it outside the sandbox, so nothing the agent does from inside can lift it. A few details matter: |
| 180 | + |
| 181 | +**Pin the exact tunnel host.** Modal tunnel hostnames look like `ta-<sandbox-id>-<port>-<random>.w.modal.host`: per-sandbox, with a random component, under a shared apex. Entries without a `*.` prefix match that one host only, which is what you want. Do **not** allowlist `*.modal.host` or `*.w.modal.host`: that opens every Modal tunnel in every workspace, which is an exfiltration path. |
| 182 | + |
| 183 | +**Clamp after provisioning, not at creation.** `outbound_domain_allowlist` only permits TLS on port 443, and once clamped to the broker the agent can no longer reach `varlock.dev` to install. Install first, then tighten. Baking varlock into a [custom image](https://modal.com/docs/guide/custom-container) skips the window entirely, which matters most when a fleet spawns many sandboxes. |
| 184 | + |
| 185 | +**Pre-arm both lists.** A list that starts empty or unset cannot be updated later, and `block_network=True` is incompatible with the allowlists. Start with `["*"]` and `["0.0.0.0/0"]` and narrow from there. To cut a sandbox off completely, set both to empty rather than reaching for `block_network`. |
| 186 | + |
| 187 | +**Non-TLS traffic needs CIDR rules.** Domain entries cover TLS on 443 only; raw TCP, UDP, and plain HTTP are matched by `outbound_cidr_allowlist`. Leaving it empty, as above, blocks all of it. |
| 188 | + |
| 189 | +The JS SDK exposes the same control as `updateNetworkPolicy()`. |
| 190 | + |
| 191 | +### Trust model |
| 192 | + |
| 193 | +Be clear-eyed about what this shape protects against. The broker holds real secrets inside Modal's cloud, so Modal's infrastructure is inside your trust boundary, same as it would be for secrets passed to any sandbox. What changes is the blast radius on your side: agents never hold secrets, so a compromised agent sandbox yields placeholders and only whatever requests your rules and egress mode allow. Rotation, policy, and audit live in one place instead of N sandboxes. |
| 194 | + |
| 195 | +Modal helps here in one respect worth naming: sandboxes are not authorized to access other resources in your Modal workspace, so a compromised agent cannot call `Secret.from_name()` to reach your other secrets. That bounds the damage to what the sandbox was given, which is exactly the thing varlock reduces to placeholders. |
| 196 | + |
| 197 | +Three practical notes: |
| 198 | + |
| 199 | +- No human is attached to the broker, so its policy must run unattended: allow rules, `block` rules, `@proxy=omit`, and strict egress. To change policy, write the edited schema into the broker and run `${VARLOCK} proxy reload`: the proxy validates the edit in its own context before applying, and a broken edit is refused and reported back. Rule changes apply to agent traffic immediately; a newly added key shows up for newly started `proxy run` commands. |
| 200 | +- The token authenticates the tunnel and, over it, unlocks the placeholder env an agent adopts. Agents hold it deliberately; it is the credential to *use* the broker, not to read its secrets, which never leave it. Treat it like any shared secret (rotate by restarting the broker with a new one). |
| 201 | +- A broker sandbox is a single point of failure for its fleet. Manage its lifetime explicitly (`timeout`, `idle_timeout`); `proxy run --url` opens a fresh tunnel per connection, so transient blips recover, and `--persist-ca` above keeps the CA stable across a broker restart. Reserve that flag for brokers: it writes the CA private key to disk, which is only reasonable because that machine already holds your real secrets. |
| 202 | + |
| 203 | +## Other topologies |
| 204 | + |
| 205 | +For local development, run the proxy on your machine instead: secrets, resolver plugins, biometric unlock, and the interactive request log stay local. Expose it through any tunnel service that carries WebSockets and reuse the same agent-side command: |
| 206 | + |
| 207 | +```bash |
| 208 | +export VARLOCK_PROXY_TOKEN=$(uuidgen) |
| 209 | +varlock proxy start --expose --port 8080 |
| 210 | +ngrok http 8080 # or cloudflared, Tailscale funnel, ... |
| 211 | +# agents: VARLOCK_PROXY_TOKEN=… varlock proxy run --url wss://abc123.ngrok.app -- <command> |
| 212 | +``` |
| 213 | + |
| 214 | +The data-plane token gates the tunnel, so a public URL is not usable by whoever finds it. Be precise about what the tunnel service itself can see, though, because it terminates the outer TLS: it observes the WebSocket handshake, which carries that token, the `CONNECT` metadata naming each upstream host, and the placeholder env the agent bootstraps. What it does not see is your real secrets, or the contents of proxied HTTPS requests, which ride an inner TLS session between the agent and your proxy. Plain HTTP has no inner session: it crosses the tunnel in absolute form, so a terminating service reads those requests in full. The proxy fails closed rather than injecting a secret into a cleartext connection, so what is exposed there is traffic carrying no injected secret. Pick a tunnel service you would trust with the token, or use the platform's private networking where it exists. |
| 215 | + |
| 216 | +The same pattern reaches a proxy on any infrastructure you run; see the [topologies overview](/sandboxes/overview/#topologies). |
| 217 | + |
| 218 | +:::note[Compared to modal.Secret] |
| 219 | +`modal.Secret` and the varlock proxy solve different halves of the problem, and they compose: the broker's bootstrap above is itself a `modal.Secret`. |
| 220 | + |
| 221 | +Where they differ: |
| 222 | + |
| 223 | +- **The agent never holds the key.** `secrets=[...]` injects real values as environment variables, so anything running in the sandbox can read and exfiltrate them. varlock hands the agent a placeholder and substitutes at the wire. |
| 224 | +- **Custody stays where you keep it.** Secrets resolve from your existing manager through [plugins](/guides/plugins/) (1Password, Vault, AWS, Doppler, ...) instead of being copied into Modal's control plane. Rotation happens at the source, with no redeploy. |
| 225 | +- **One schema.** Your `.env.schema` describes every value, its type, and its routing rules in one declarative layer that is legible to both people and agents, rather than a dictionary of strings. |
| 226 | +- Plus per-request policy on host, path, and method, [hot reload](/guides/proxy/running/#editing-the-schema-while-a-session-is-running), response scrubbing, your own audit log, and the same setup on any other platform. |
| 227 | +::: |
0 commit comments