Locks · lockhttp
How lockhttp works
Why per-request decisions; the gate, the console and the hub; the verdicts; what the agent sees; the Claude API checks; the limits. The short version is in the README.
Moved from the README, whole.
lox runruns the hub and the lockhttp console, writes.lockfamily/up.jsonand serves the decisions in its terminal; it replaces lockhub's deprecatedlox up.
Why LockHTTP? #
An agent that can read the web can also write to it: a POST to a webhook,
a git push, a package publish, a secret tucked into a query string, a
Claude API call that hands an MCP server to the model. An allowlist of
domains does not tell these apart from a documentation read, and approval
prompts inside the agent's own process can be configured away by that
process.
LockHTTP moves the decision out of the agent and onto the network path:
| Domain allowlist | In-agent approval | LockHTTP | |
|---|---|---|---|
| Reads of trusted hosts pass without a prompt | ✅ | ✅ | ✅ |
| A write to a trusted host still needs a human | ❌ | ✅ | ✅ (per request when decrypting) |
| Approval cannot be switched off from the agent side | ✅ | ❌ | ✅ |
| Policy loosening needs a human confirmation | ❌ | ❌ | ✅ |
| Secrets sent to the wrong host are refused | ❌ | ❌ | ✅ (when decrypting) |
Claude API relays (mcp_servers, server tools, Files) need a human |
❌ | ❌ | ✅ (when decrypting) |
| Each decision traced to container, agent, task | ❌ | ❌ | ✅ |
How it works #
Three pieces, each in a container of its own, and the hub. Only the
policy the console serves decides what needs a human, and only the human
answers, on the hub. LockHTTP runs hub-attached only: lox run runs its
console and the hub, and lea runs the gate with the hub flags lox run
hands it.
agent container (internal network only, no route out)
HTTP(S)_PROXY=http://<agent>:<token>@lea-proxy:3130
│
▼
gate container (shares the proxy container's network)
lockhttp gate :3130 (uid 10003 "gate", never root) ──▶ squid 127.0.0.1:3128 ──▶ Internet
lockhttp signer (its own container, uid 10004, holds the CA key; only with decryption)
│ socket volume <project>_<lock>-run: /run/lockhttp/user-<profile>.sock
│ (gate and console only; peer uid checked by the kernel)
▼
console container (lox run's service lock-<lock>, its terminal)
uid 10021 "lockhttp" · the policy, the approved policy, the audit
serves the approved policy to the gate · asks you about every loosening
·
gate ──▶ lockhub (lox run's hub network; only lea's proxy joins it)
held requests become signed tickets · you approve on a facade (lox approve, lea's deck, a phone)
- The agent's HTTP client sends every request to the gate, a Go HTTP(S) proxy. In lea the agent's container has no other route out: the proxy variables are a convenience, the network is the boundary.
- The gate decides each request with the policy the console serves it
(
policy.get, polled every 2 s). It never reads a policy file. - A request that is free goes out through squid, which keeps its own floor (DoH, IP literals, private ranges, ports). A request that needs a human is held (60 s by default) as a signed ticket on the hub, where you approve or deny it.
- With a local CA and the signer, the gate decrypts HTTPS and decides
each request by method, path, headers and body. Without them, it decides
a
CONNECTat the domain only (see Limits).
Verdicts #
| Verdict | What happens |
|---|---|
| FREE | The request goes out. The gate audits it (JSONL, one file per day). |
| ASK | The gate holds the request and asks the hub: a ticket you approve or deny (with a reason) on a facade. Every ticket is NeverAuto: the hub has no auto mode for the gate's asks. |
| DENY | 403 with an X-LockHTTP-Code header, at once. |
What an ASK becomes depends on what the gate sees:
- A
CONNECTdecided at the domain (no decryption, or a spliced or internal host):yopens container × host × port forconnect_grant(10 minutes by default), for reads and writes, content unseen. The screen says so. - A decrypted or plain HTTP request:
yapproves that request. If the agent's client already gave up (hold time passed,E_PENDING),ycreates a single-use authorisation for the identical request, valid 10 minutes. - Durable openings are policy rules, which you write and confirm in the
console's
:review.
Requests of identical fingerprint (container, method, URL, media type, every end-to-end header, normalised body) share one screen and one decision; a request to the same URL with another body is a separate screen.
What the agent sees #
A refusal is a 403 Forbidden with a plain-text body that says why and
what to ask of the human, and two headers: X-LockHTTP-Code and
X-LockHTTP-Request (the id to find in the console and the audit). For a
CONNECT it is the CONNECT's own response, which clients show as a proxy
error. In hub mode an E_PENDING also names the hub ticket the request
waits on (X-LockHTTP-Ticket). A forwarded response carries X-LockHTTP-Request too (decrypted or
plain HTTP); a target the gate cannot reach is a 502.
| Code | Meaning | What the agent should do |
|---|---|---|
E_PENDING |
The request waits for the human past the hold time. | Retry the same request in a moment: once approved it goes through. |
E_NO_CONSOLE |
No console is running: every ASK is refused at once. | Ask the user to run lox run. |
E_DENIED |
The human denied it (the reason is in the body). | Do not retry; ask the user. |
E_TIMEOUT |
The hub ticket expired before anyone answered it (the ticket is in X-LockHTTP-Ticket). |
Send the same request again: it is asked again. |
E_RULE |
A deny rule or the deny list refuses it. | Ask the user to change the rule. |
E_FLOOR |
The network floor: IP literal, DoH, cloud metadata, port, single-label host, SNI or Host mismatch, h2c, nested CONNECT, a non-absolute URL. |
Use a named public host on 80 or 443. |
E_SECRET_EGRESS |
A known credential sent outside its own hosts, a Claude credential that is not the container's, or the value of a secret the gate injects. | Never send a credential to another host. |
E_BODY_TOO_LARGE |
The body is over buffer_limit and cannot be held for a human. |
Send less, or ask for a rule. |
E_SOURCE_UNKNOWN |
The gate cannot tell which container sent the request. | Use the proxy address lea gave. |
E_INJECT |
The policy injects a credential for this host, and the gate's secrets file lacks it or does not load. | Ask the user to check the gate's secrets file; never send the credential yourself. |
E_HOST_UNKNOWN, E_CLAUDE_API_PARAM, E_EXFIL_SUSPECT, E_METHOD and
E_PINNED are registered codes too; in v0.1.0 a refused ASK reaches the
agent as E_DENIED, E_TIMEOUT, E_PENDING or E_NO_CONSOLE, and
E_PINNED appears in the gate's audit only (a client that rejects the
gate's certificate closes the connection before any response).
Claude API allowlist #
The four hosts Claude Code reaches (api.anthropic.com, claude.ai,
console.anthropic.com, statsig.anthropic.com) are free at the domain.
Any other host under anthropic.com, claude.ai or claude.com asks
(NeverAuto), unless a rule says otherwise.
Once TLS is decrypted, the Claude API is a generic relay and is checked per request:
| Request | Verdict |
|---|---|
POST /v1/messages, /v1/messages/count_tokens |
FREE only when the body is one unambiguous JSON object with no mcp_servers, no container, and no server tool other than web_search (custom tools and the client-run bash, text_editor, computer, memory are fine). Otherwise ASK, red, NeverAuto. |
OAuth paths (/v1/oauth/, and /oauth/ on claude.ai) |
FREE |
/v1/files* |
ASK, NeverAuto |
| Other reads on the four hosts | FREE |
| Other writes | ASK, NeverAuto |
A credential (Authorization, x-api-key) not in the caller's list, or two credentials |
DENY E_SECRET_EGRESS (with lea's credentials.json) |
Duplicate keys, and keys that differ from mcp_servers, tools or
container only by case, make the body ambiguous: it asks.
Limits #
- Without decryption, a
CONNECTopens reads AND writes. A trusted host is free at the domain, and an approvedCONNECTopens its host forconnect_grant; the gate does not see what goes through. Rules withmethodsorpathcannot apply at the domain. The Claude API allowlist needs decryption too.lockhttp doctorwarns when no CA is given. - HTTP/1.1 only to clients when decrypting. The gate offers ALPN
http/1.1; it does not speak HTTP/2 to clients, and refusesUpgrade: h2c. - A WebSocket session is one approval. The upgrade request is decided; the frames that follow are not.
- Heuristics run in shadow by default. The exfiltration score is logged,
not enforced, until you set
heuristics = "enforce". - Asks wait side by side. Each held request is its own hub ticket; identical requests share one.
- The agent name is declarative. The container is read from the network
the connection arrived on; the agent name comes from
Proxy-Authorizationand is for the audit only. Agents of one container share a uid, so rules scope to containers, never to agents. - No MCP server in v0.1.0: agents meet LockHTTP as a proxy, and its
403pages tell them what to ask of the human.