LockHTTP is the proxy every outbound HTTP(S) request of an AI agent goes
through. Reads of hosts you trust pass. Anything that sends data, triggers an
action or reaches an unknown host waits for your approval, on
lockhub (lox approve, the lea
deck or a paired phone), where the agent cannot reach it. Every decision is traced to the container, the agent and the session
that asked.
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 up runs its
console and the hub, and lea runs the gate with the hub flags lox up
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 up'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 up'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 CONNECT at 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 CONNECT decided at the domain (no decryption, or a spliced or
internal host): y opens container × host × port for connect_grant
(10 minutes by default), for reads and writes, content unseen. The
screen says so.
A decrypted or plain HTTP request: y approves that request. If the
agent's client already gave up (hold time passed, E_PENDING), y creates
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.
Quick start with lea
LockHTTP runs in Docker and hub-attached only: lockhub's
lox up runs the hub and the lockhttp console for your projects directory,
and lea runs the gate beside its
proxy container when LEA_LOCKHTTP=1, with the hub flags lox up writes in
.lockfamily/up.json. The contract between lockhttp and lea (files, mounts,
uids, network rules) is in docs/lea.md; the one between
lox up and lea in lockhub's docs/lox-up.md.
# 1. Install the launcher (see Install): lea runs the gate from the image it pins
V=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/DavidGodefroid/lockhttp/$V/scripts/install.sh"
less install.sh && LOCKHTTP_VERSION=$V sh install.sh
# 2. In your projects directory: a lockhttp lock, then the hub and its console
cd ~/projects
lox add lockhttp web # the policy: .lockhttp/config.toml, which lox up reviews and copies
lox up # runs the hub and the console, registers the gate's key
lox attach web # the console's terminal: approve its first policy (detach: Ctrl-P Ctrl-Q)
# 3. Start lea with LockHTTP on: its gate asks the hub
export LEA_LOCKHTTP=1
lea
# 4. Approve the held requests on a facade of the hub
lox approve
The console runs as the lockhttp account (uid 10021) in lox up's
service; lea's gate runs as gate (uid 10003) in a container of its own
and shares only the console's socket volume with it. Without a trusted
up.json naming a lockhttp lock, lea does not start the gate: it says to
run lox up. lockhttp gate refuses to start without its hub flags
("launched by lox up only").
Install
LockHTTP runs in Docker. On the host, scripts/install.sh installs only the
lockhttp launcher, a shell script that runs every command in the release's
image (ghcr.io/davidgodefroid/lockhttp:X.Y.Z, amd64 and arm64). Download
the script from the release you install, read it, then run it:
V=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/DavidGodefroid/lockhttp/$V/scripts/install.sh"
less install.sh
LOCKHTTP_VERSION=$V sh install.sh
It verifies the keyless cosign signature of checksums.txt against this
repository's release workflow, checks the archive, verifies the image's
cosign signature and writes its digest into the launcher, which runs the
image by that digest. The launcher lands in /usr/local/bin, root-owned
(LOCKHTTP_INSTALL_DIR to change it). cosign is required unless
LOCKHTTP_SKIP_COSIGN=1, which verifies nothing and runs the image by its
tag. Linux (Docker Engine) and macOS (Docker Desktop); on Windows, WSL 2
with Docker Desktop.
Adds a profile with the default policy to the project's .lockhttp/config.toml (never rewrites one already there; never takes a secret).
lockhttp init <agent>
you, on the project
Nothing to write: lockhttp has no agent files (lea routes the agent's HTTP through the gate).
lockhttp doctor [--fix [--yes]] [--json] [--gate ADDR] [--ca-cert FILE]
you, on the project
Separated setup, the project's policy and its permissions, CA, gate; --fix repairs the permissions.
lockhttp policy show|edit|path
lockhttp, volume lockhttp-home
Prints, edits or locates the console's copy of the policy.
lockhttp console
lockhttp, container lockhttp-console
The policy console (under lox up, its own service runs it). It must run in a terminal.
lockhttp gate
gate, container lockhttp-gate
The proxy. It needs the hub flags (--hub, --hub-key-file, --instance): lea passes them from lox up's up.json.
lockhttp version, lockhttp update [vX.Y.Z]
The launcher's and the image's versions; a newer release's launcher, checked the same way, and its image.
LOCKHTTP_IMAGE runs an image of your own (docker build -t lockhttp .
from a clone), LOCKHTTP_DOCKER_ARGS adds docker run arguments. There is
no host install: the image refuses lockhttp install. Coming from v0.1.0's
sudo lockhttp install: migrating from a host
install.
The demo above is generated by scripts/brand/demo.py:
python3 scripts/brand/demo.py > site/assets/demo.svg.
Accounts, volumes, the exact docker run of each command, and what Docker
does and does not separate: docs/docker.md. Every command
and flag: docs/usage.md.
Configuration
The policy lives in the console account's ~/.config/lockhttp/config.toml,
one table per profile. The console reads it, reloads it when it changes,
and holds every change that loosens it until you confirm it with :review.
Unknown keys and keys that look like secrets are refused.
[profiles.default]
mode = "enforce" # enforce | shadow
heuristics = "shadow" # off | shadow | enforce
buffer_limit = "10MiB" # largest body held for a human
hold = "60s" # how long a request waits for the human (max 5m)
connect_grant = "10m" # how long an approved CONNECT opens its host
trusted = ["go.dev", "pkg.go.dev", "github.com", "api.github.com"]
splice = [] # never decrypted (certificate pinning)
deny = [] # always refused
All profile keys
Key
Default
Notes
production
false
No rule may be auto, and the console treats the profile as production.
mode
enforce
shadow: what would ask or be denied passes and is logged as such.
heuristics
shadow
Exfiltration score: off (not computed), shadow (logged), enforce (a score ≥ 3 asks, NeverAuto, red).
buffer_limit
10MiB
B, KiB, MiB, GiB or bytes; more than 0.
hold
60s
Go duration, at least 1s, at most 5m.
connect_grant
10m
At least 1s.
idle_timeout
24h
Console session limit.
max_session
24h
Console session limit.
trusted
[] (init writes a docs set)
Reads (GET, HEAD, OPTIONS without body) are free.
splice
[]
Never decrypted: decided at the domain.
deny
[]
Always refused (E_RULE).
redirect_cdn
[]
"origin>cdn": a 3xx from origin to cdn lets the same container GET that exact URL once within 60 s. Eight GitHub, Docker, Go and PyPI pairs are built in.
[[profiles.P.internal_hosts]]
none
host, ports (required), read (false). May resolve to a private address.
Hosts are names: example.com, or *.example.com for its subdomains (not
example.com itself). IP literals are refused. The full reference, with
the evaluation order and what counts as a loosening, is in
docs/policy.md.
Policy examples
[profiles.default]
trusted = ["docs.rs", "*.readthedocs.io"] # free reads of docs
splice = ["pinned.example.com"] # a client that pins its certificate
deny = ["pastebin.com"]
# A container-scoped rule that auto mode may answer
[[profiles.default.rules]]
id = "npm-audit"
container = "lea-ab12cd34ef"
host = "registry.npmjs.org"
methods = ["POST"]
path = "/-/npm/v1/security/advisories/bulk"
verdict = "auto"
max_body = "256KiB"
expires = "2026-12-31"
# Every write under an API path asks (NeverAuto), even on a trusted host
[[profiles.default.rules]]
id = "issues-write"
host = "api.github.com"
methods = ["POST", "PATCH"]
path = "/repos/acme/shop/issues*"
verdict = "ask"
# Refuse a host for every container
[[profiles.default.rules]]
id = "no-gists"
host = "gist.github.com"
verdict = "deny"
# An internal host on a private address: free reads on 8443
[[profiles.default.internal_hosts]]
host = "git.corp.example"
ports = [8443]
read = true
To try a policy before enforcing it, set mode = "shadow": what would ask
or be denied passes and is audited with "decision": "shadow-ask" or
"shadow-deny". The floor and secret matches stay enforced.
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 up.
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, or a Claude credential that is not the container's.
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_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
Duplicate keys, and keys that differ from mcp_servers, tools or
container only by case, make the body ambiguous: it asks.
Security model
The gate is the enforcement point on the network path; the hub's facades
are the only place a human decision about a request is made, and the
console the only place a loosening of the policy is. In short:
The console is in its own container, under its own account.lockhttp console runs as lockhttp (uid 10021) in lox up's service,
without a log driver. Its socket is reachable only by the
lockhttp-clients group (the image's gate account), checked with the
kernel's peer credentials, and its socket volume is mounted in the
gate's container only, never in an agent's. Anyone who can run
docker on the host can reach the console's container and volumes: keep
the agent away from the Docker socket.
NeverAuto. Every hub ticket of the gate is NeverAuto: the hub
cannot check a verdict = "auto" rule against the console's policy, so
nothing answers a held request but a human. Secrets are denied.
A decision is shared only by identical requests. Same container,
method, URL, significant headers and normalised body; anything else is
its own screen.
Hub-attached only. The gate does not start without its hub flags,
and nothing falls back to another approver when the hub is unreachable:
the ticket stays pending until the hub answers.
Fail closed. No console: every ASK is refused at once
(E_NO_CONSOLE), while FREE goes on with the last policy the console
served. Unknown source, unreadable source map: refused. An unreadable
credentials file: every Claude credential is denied. A policy whose
fingerprint does not match: the previous one is kept.
The gate never reads a policy. It uses what the console serves, and
checks it against the console's fingerprint.
The signer holds the CA key. The gate asks lockhttp signer for a
24-hour leaf per host, over a Unix socket restricted to allowed uids; it
never sees the CA key.
The AI tightens, the human loosens. Any change that widens what passes
without a human (a trusted host, a free or auto rule, shadow mode, a
longer grant, a removed deny...) waits for your :review.
Traced. The gate's JSONL audit and the console's audit carry
request_id, container, agent, attribution, via, never a body, a
query string or a header value.
Without decryption, a CONNECT opens reads AND writes. A trusted host
is free at the domain, and an approved CONNECT opens its host for
connect_grant; the gate does not see what goes through. Rules with
methods or path cannot apply at the domain. The Claude API allowlist
needs decryption too. lockhttp doctor warns 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 refuses Upgrade: 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-Authorization
and 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 403
pages tell them what to ask of the human.
The Lock family
LockHTTP is one of a family of human-gated tools for AI agents, built on the
same console, separation and approval model:
Command lockhttp is the proxy every outbound HTTP request of an AI agent goes through: reads of trusted hosts pass, anything that sends data, triggers an action or reaches an unknown host waits for a human in a console the agent cannot reach.
Command lockhttp is the proxy every outbound HTTP request of an AI agent goes through: reads of trusted hosts pass, anything that sends data, triggers an action or reaches an unknown host waits for a human in a console the agent cannot reach.
Package ca is lockhttp's local certificate authority: one per installation (P-256, pathlen 0, 90 days), trusted only inside lea's containers, and the 24-hour leaf certificates the signer issues for the gate's MITM.
Package ca is lockhttp's local certificate authority: one per installation (P-256, pathlen 0, 90 days), trusted only inside lea's containers, and the 24-hour leaf certificates the signer issues for the gate's MITM.
Package decide is lockhttp's decision engine: one request (a CONNECT seen at the domain, or a decrypted or plain HTTP request) and the approved policy in, a verdict out: FREE, ASK or DENY.
Package decide is lockhttp's decision engine: one request (a CONNECT seen at the domain, or a decrypted or plain HTTP request) and the approved policy in, a verdict out: FREE, ASK or DENY.
Package httpconsole is lockhttp's console backend for lockkit: the method the gate calls (policy.get) and the policy read from the console account's config file.
Package httpconsole is lockhttp's console backend for lockkit: the method the gate calls (policy.get) and the policy read from the console account's config file.
Package signer keeps the CA key away from the gate (decision D3): the signer process alone reads the key and issues leaf certificates over a Unix socket to the uids it allows; the gate asks it for each host it decrypts.
Package signer keeps the CA key away from the gate (decision D3): the signer process alone reads the key and issues leaf certificates over a Unix socket to the uids it allows; the gate asks it for each host it decrypts.
Package wire is the protocol between the gate (a lockkit client) and the console: the methods, their parameters and results, and the audit record both sides write.
Package wire is the protocol between the gate (a lockkit client) and the console: the methods, their parameters and results, and the audit record both sides write.
Click to show internal directories.
Click to hide internal directories.
go.dev uses cookies from Google to deliver and enhance the quality of its services and to
analyze traffic. Learn more.