lazy documentationv0.22

Just-in-time credential injection

No agent process ever holds your real Anthropic or Cursor credential.

Lazy's proxy is always on and every agent's model traffic goes through it. That makes one more thing possible: the credential can stay with the proxy. At launch lazy hands the agent a placeholder — a per-task token minted by the daemon — and the proxy swaps in the real credential in the last hop before the request leaves the machine.

The property this buys: a task container's environment, its docker run argv, its process listing, anything it writes to a log or a file, and anything a prompt-injected agent can exfiltrate contain a placeholder that is worthless anywhere except against this machine's proxy — and only until the task ends.

The flow

launch                         request                        upstream
──────                         ───────                        ────────
daemon mints a placeholder
for (role, task, env var)
        │
        ├─► agent env: ANTHROPIC_API_KEY=sk-ant-api03-lazy-<random>
        │
        └─► registry: ~/.lazy/daemon/<slug>/proxy-tokens.json (0600)

                    agent ──── x-api-key: sk-ant-api03-lazy-… ──► proxy
                                                                    │
                                              look the value up in the registry
                                              → grant: role=agent, task=abc123
                                                                    │
                                              look up the credential for the
                                              target this request is going to
                                                                    │
                    proxy ──── Authorization: Bearer <your real token> ──► Anthropic

The placeholder goes into the same environment variable the real credential would have used (CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CURSOR_API_KEY), and mimics that credential's shape — sk-ant-oat01-lazy-…, sk-ant-api03-lazy-…, key_lazy_…. Clients validate the format of the key they are handed, and they choose their auth header from which variable is set, so keeping both means the agent's request is byte-identical to what it would otherwise have sent. The proxy's job is a substitution, not a protocol change.

Attribution comes from the token, not from the agent

Before this, the proxy learned who was calling from x-lazy-role and x-lazy-task-id headers that lazy set at launch — self-reported, and any value the agent cared to send would have been believed. Now the placeholder is the identity: the proxy resolves it to a grant and takes role and task from there. The headers remain as a fallback for traffic with no grant (a host session on its own login), and are ignored whenever a grant is present.

The grant carries the caller's role, which is also how the proxy decides where to forward: role → configured upstream, resolved per request. Routing on authenticated evidence rather than a self-reported header is the same argument as attribution — an agent that could name its own role could pick its own upstream. Traffic with no grant goes to the primary upstream, as before.

Per-target credentials are data

The proxy does not forward whatever the client presented. It looks up the credential for the target it is actually calling, which matters on a [[proxy.fallback]] reroute: the primary is Anthropic, but the fallback may be a local Ollama or someone else's endpoint.

Target's configured credential What the proxy sends
credential = "anthropic" your real Anthropic credential, in the form that target expects
credential = "none" (default) nothing — the presented credential is stripped, not passed along
resolvable but missing/expired 401 if it is the primary; that hop is skipped if it is a fallback

The map is keyed by origin (scheme + host + port), so two upstreams that differ only in path are the same target as far as credentials go. A fallback whose origin collides with an already-credentialed upstream while declaring credential = "none" would silently inherit that credential, so lazy refuses to start rather than run a credential map that does not mean what it says.

A target that needs no credential today (local Ollama) and one that will need its own tomorrow (hosted Ollama) differ only in the data, not in the code path — the credential is looked up per target, never inferred from the backend type.

Role upstreams are targets too. A role's endpoint under [models.roles.*] is the upstream the proxy forwards that role to, so it enters the same map under the same rules: an ollama role's upstream gets none, an Anthropic-native one gets the real credential, and an origin collision with an already-credentialed upstream is refused at startup rather than silently inherited.

This is also what makes an ollama role launchable without an Anthropic credential at all. Its placeholder is minted over synthetic values rather than the user's token — the grant exists so the proxy can authenticate the caller and learn which role it is, not to carry a secret — and the ollama upstream then receives no credential. An ollama-only project stays exempt from the daemon's credential gate while being fully proxied.

Failure semantics

Every failure is loud, per lazy's no-silent-fallback rule.

They also show up live: because attribution here is derived from the grant rather than a self-reported header, lazy watch can stream this proxy's traffic per task (net> lines) and flag a credential refusal with its actual cause — a container presenting a placeholder whose grant is gone. That is what makes watch agent-agnostic; see the module header of src/proxy/activity.ts.

Note what the attribution IS: the grant carries the task ref the agent was launched with — taskRef(task), i.e. the task's code, or its short id when it has none — not the full task id. Anything filtering proxy traffic by task must accept every form the task answers to; matching a full id against a ref finds nothing, which is exactly how lazy watch once printed its traffic header and then stayed blank for a whole turn.

Accepting every form does NOT mean loose prefix matching in both directions: a task's code must match exactly, because codes collide by prefix in practice (add-agent-to-unblock vs add-agent-to-unblock-clone-1) and a filter that merges two tasks' traffic is worse than one that finds none. Prefix matching survives only for the hex-shaped short id an operator types, with a minimum length. Relatedly, a filter parameter that names no task at all (an empty string or list) is refused rather than treated as "no filter" — silently widening a scoped request to every task's traffic is the opposite of what was asked for.

Lifetime

Grants live in proxy-tokens.json in the daemon's per-project state directory (~/.lazy/daemon/<slug>/, mode 0600) — the same posture and location as the daemon auth token and the MCP token registry, and deliberately not under the project root, which every task container bind-mounts.

Cursor

cursor-agent sends its key in more than one place and its -H flag does not cover every request, so the placeholder rides in the URL instead: /_lazy/cursor/<placeholder>/<upstream path>. The proxy resolves that segment to a grant, substitutes the real key wherever the CLI put it — header or request body — and forwards the rest verbatim. Body substitution is bounded to requests declaring content-length ≤ 64 KiB, so the bidirectional agent stream is never buffered. The token in the path is never logged; a malformed route is reported by segment count only.

Exception — a host login session. cursor-agent login produces a session credential rather than an API key. There is no key to replace, so lazy routes that traffic under /_lazy/cursor/-/…, forwards the session credential untouched, and records the request unattributed. Containers always require an API key, so a container always gets a placeholder; this case only arises on the host.

Verified: OAuth token refresh does not touch the proxy

The design raises an obvious question — if Claude Code holds an OAuth token, does it perform a refresh flow, and would that refresh arrive at the proxy carrying a placeholder that is not a refreshable token?

It does not. Checked empirically against roughly 8 MB of this project's own live proxy audit records: every request to ANTHROPIC_BASE_URL is POST /v1/messages (173), HEAD /api/hello (19), or POST /v1/messages/count_tokens (8). No auth, token, or refresh endpoint appears at all. Refresh flows target console.anthropic.com, which is a different host and is not what ANTHROPIC_BASE_URL points at — and a token minted by claude setup-token carries no refresh token to exchange in the first place.

Two consequences worth stating, in case this changes:

  1. If Anthropic ever routes refresh through the messages base URL, the proxy would see it as an unrecognised path carrying a placeholder. The right handling then is to answer it from the proxy with the real credential's refresh result, not to forward the placeholder — the client must never be handed a real token in the response either.
  2. Nothing here depends on the token being non-refreshable; the placeholder is swapped per request, so a rotated real credential is picked up on the next request with no relaunch.

What this supersedes

cursor-hardening-batch item 8 — "real secrets in docker run argv" — is resolved by this change for every credential that goes through a launch path: the argv now carries a placeholder. Any credential that does not go through these paths is out of scope here and still belongs to that task.