Locks · lockhttp
LockHTTP policy reference
The policy is the profile the human writes in the console account's config
file, ~/.config/lockhttp/config.toml: lockhttp profile add writes a
first one in the project's .lockhttp/config.toml, which lox run reviews
and copies there. The console reads it, validates it, has you approve it, and serves
the approved policy to the gate (policy.get) with its sha256 fingerprint.
The gate never reads a policy file: it uses what the console serves,
checks the fingerprint, and keeps the previous policy when they do not
match.
This page documents v0.1.0 as the code implements it
(internal/policy, internal/decide, internal/gate).
File format #
TOML, one table per profile:
[profiles.default]
production = false
mode = "enforce"
heuristics = "shadow"
buffer_limit = "10MiB"
hold = "60s"
connect_grant = "10m"
idle_timeout = "24h"
max_session = "24h"
trusted = ["go.dev", "*.readthedocs.io"]
splice = []
deny = []
redirect_cdn = []
[[profiles.default.internal_hosts]]
host = "git.corp.example"
ports = [8443]
read = true
[[profiles.default.rules]]
id = "npm-audit"
container = "lea-ab12cd34ef"
host = "registry.npmjs.org"
methods = ["POST"]
path = "/-/npm/v1/security/advisories/bulk"
verdict = "ask"
max_body = "256KiB"
expires = "2026-12-31"
note = "npm audit in the shop container"
An unknown key is refused, as is any key or value that looks like a secret
(a DSN with an embedded password, a control or formatting character).
Profile names use letters, digits, _, - and ., starting with a letter
or digit (at most 64 bytes); a production profile's name must be at least 2
characters and not y, yes, n, no or ok.
An error names the key, and for a list the entry's place in the file
(trusted entry 2, rules entry 3 host), with the allowed values; it never
repeats the value given, which may be a pasted secret. A rule is named by
its place, not by its id. Profile names (table names) are keys and may
be named.
Profile keys #
| Key | Type | Default | Validation and meaning |
|---|---|---|---|
production |
bool | false |
No rule may have verdict = "auto"; the console treats the profile as production. |
mode |
string | "enforce" |
"enforce" or "shadow". See Shadow mode. |
heuristics |
string | "shadow" |
"enforce", "shadow" or "off". See Exfiltration heuristics. |
buffer_limit |
size | "10MiB" |
Largest body the gate holds for a human. "512B", "256KiB", "10MiB", "1GiB" or a number of bytes; more than 0, at most 1 TiB. |
hold |
duration | "60s" |
How long the gate holds a request for the human before answering E_PENDING. Go duration, at least 1s, at most 5m (the console's approval timeout). |
connect_grant |
duration | "10m" |
How long an approved CONNECT opens container × host × port. At least 1s. |
idle_timeout |
duration | "24h" |
The console session's idle limit (lockkit). At least 1s. |
max_session |
duration | "24h" |
The console session's total limit (lockkit). At least 1s. |
trusted |
host list | [] |
Reads of these hosts are free; at the domain (no decryption), a CONNECT to them is free. |
splice |
host list | [] |
Never decrypted: a CONNECT to them is decided at the domain, even when the gate decrypts. For clients that pin certificates. |
deny |
host list | [] |
Always refused (E_RULE). |
redirect_cdn |
list of "origin>cdn" |
[] |
A 3xx from origin whose Location is on cdn lets the same container GET that exact URL once, without a body, within 60 s. Both sides are host patterns. |
internal_hosts |
array of tables | [] |
See Internal hosts. |
rules |
array of tables | [] |
See Rules. |
inject |
array of tables | [] |
See Credential injection. |
Built-in redirect_cdn pairs, always active:
ghcr.io>pkg-containers.githubusercontent.com
github.com>objects.githubusercontent.com
github.com>release-assets.githubusercontent.com
github.com>codeload.github.com
api.github.com>codeload.github.com
registry-1.docker.io>production.cloudflare.docker.com
proxy.golang.org>storage.googleapis.com
pypi.org>files.pythonhosted.org
Host patterns #
example.com: that host only.*.example.com: any subdomain ofexample.com, notexample.comitself.*: any host. Only in a rule'shost.
Hosts are lower-cased and a trailing dot is dropped. A host must have at least two labels (except an internal host), each a valid DNS label; IP literals are refused everywhere.
Internal hosts #
[[profiles.default.internal_hosts]]
host = "git.corp.example" # an exact name, may be single-label ("gitlab")
ports = [443, 8443] # required, 1-65535
read = true # default false
An internal host is an exact name (a *. pattern is refused). It may resolve to a private address, and may use the ports
it lists (any other host is limited to 80 and 443). It is never decrypted:
a CONNECT to it is decided at the domain. With read = true, a
CONNECT and a read are free; otherwise every request asks (NeverAuto).
Rules #
| Key | Required | Meaning |
|---|---|---|
id |
yes | Unique in the profile. 1-64 characters: letters, digits, _, -, ., starting with a letter or digit. |
container |
no | The container it applies to (lea's container name, local without a sources.json); empty: every container. |
agent |
Refused. An agent name is declarative; rules apply to a container or to all. | |
host |
yes | A host pattern, or *. |
methods |
no | HTTP methods (upper-cased); empty: every method. |
path |
no | An exact path, or a prefix when it ends with * (/v1/items*); must start with /; empty: every path. Matched against the escaped path, without the query string. |
verdict |
yes | free, auto, ask or deny. |
max_body |
no | Size: a body larger than this turns the rule's verdict into ASK, NeverAuto. 0 or absent: the buffer_limit. |
expires |
no | YYYY-MM-DD: the last day (UTC) the rule applies. Absent: never expires. |
note |
no | Free text for you. |
| Verdict | Effect when the rule decides |
|---|---|
free |
FREE. |
auto |
ASK. The gate asks lockhub, which has no auto mode for its asks: an auto rule asks like ask (see below). |
ask |
ASK, NeverAuto. |
deny |
DENY, E_RULE. |
verdict = "auto" is refused when the file is parsed if the profile is
production, the host is *, the host is spliced, the host sends messages
(Slack, Gmail API, Microsoft Graph and Outlook, Discord, Telegram, Twilio,
SendGrid, Mailgun, Postmark, Pushover, ntfy, webhook.site, Office
webhooks), the rule's methods can publish to a package or image registry
(npm, PyPI, crates.io, RubyGems, Docker Hub, GHCR, Quay, *.pkg.dev,
*.azurecr.io, NuGet, Maven Central, uploads.github.com), the path
contains git-receive-pack, or the rule allows a write to any path of
github.com, gitlab.com, bitbucket.org or codeberg.org.
The gate runs hub-attached only, and every hub ticket it makes is
NeverAuto: the hub cannot check an auto rule against the console's
policy, so a human answers every held request, auto rule or not. The
verdict stays valid in a policy file (and keeps the checks above).
Evaluation order #
For each connection, then each request, the first step that decides wins.
At the gate, before the policy #
- Source. With
--sources, the container is the one whose network owns the local address the connection arrived on; a connection on 127.0.0.1 must carry the keyproxy token and name a known container inX-Lea-Source. Anything else:E_SOURCE_UNKNOWN. Without--sources, every connection is containerlocal. - Request shape. A non-
CONNECTrequest must use an absolutehttp://URL whoseHostheader matches; inside a decrypted tunnel theHostheader must match theCONNECThost. OtherwiseE_FLOOR.
The floor (no policy loosens it) #
- In this order: no host; an IP literal; a cloud metadata name
(
metadata,metadata.google.internal,metadata.goog,instance-data,instance-data.ec2.internal); DNS over HTTPS (a host starting withdoh.ordns., a known DoH provider, and for a request a path ending/dns-query,/resolve?name=or anapplication/dns-messagetype); aCONNECTinside a tunnel; a TLS server name that differs from theCONNECThost;Upgrade: h2c; a port other than 80 and 443, unless it is one of an internal host's ports; a single-label host that is not an internal host. AllE_FLOOR.
CONNECT routing #
- A
CONNECTto port 443 of a host that is neither spliced nor internal, when the gate decrypts (--ca-certand--signer): the deny list is checked (E_RULE), then the tunnel is decrypted and each request inside goes through steps 6 onwards. The gate offers HTTP/1.1 only. - Any other
CONNECTis decided at the domain: a live connect grant for container × host × port is FREE; otherwise steps 7 onwards decide, with no method, path or body known. Once open, the client's first bytes must be a complete TLS ClientHello naming the host on port 443 (a ClientHello on other ports must name it too), or the tunnel is closed.
Grants (decrypted or plain HTTP requests) #
- First the deny list and the secret check (steps 7 and 8) apply: no
grant lifts them. Then a single-use authorisation for this exact request fingerprint (created
when you approved a request whose client had left) is FREE and consumed.
A
GETwithout body of an exact URL a listed CDN redirect opened for this container within 60 s is FREE and consumed.
The policy (internal/decide) #
-
Deny list. A host on
deny: DENYE_RULE. -
Secrets (requests only, not a
CONNECT). A known credential format in the path, query (raw and unescaped), header values (exceptCookie) or held body, sent to a host it does not belong to: DENYE_SECRET_EGRESS. Formats: GitHub tokens (GitHub hosts), Anthropic API keys (api.anthropic.com,console.anthropic.com), AWS access keys (*.amazonaws.com), Slack tokens (Slack), OpenAI keys (api.openai.com), Google API keys (*.googleapis.com), npm tokens (registry.npmjs.org), GitLab tokens (GitLab), and private keys (no host). Before this, the value of any secret of the gate's secrets file (as is, URL-escaped or in base64) anywhere in what the agent sent: DENYE_SECRET_EGRESS(rulesecret:injected), whatever the host, in shadow mode too; the audit keeps no path for it. See Credential injection. -
Internal hosts. An internal host with
read = true: FREE for aCONNECTor a read. Any other request to an internal host: ASK,NeverAuto. -
Exfiltration score (requests only, unless
heuristics = "off"): computed now, applied in step 15. -
Rules, most specific first. Among the rules that are active (not past
expires), whose host pattern matches, whosecontaineris empty or the caller's, and whose methods and path match the request, the one with the highest specificity decides. At the domain (aCONNECT), only rules withoutmethodsand withoutpathtake part. Specificity adds:Part of the rule Points a container1000 an exact host 400 + its length a *.domainhost100 + its length *0 an exact path 300 a prefix path ( /x*)50 + its length (at most 240, so always below an exact path) methods10 So a container rule beats an all-containers rule, an exact host beats a wildcard, an exact path beats a prefix, a longer prefix beats a shorter one, and listed methods beat none. On a tie, the rule whose
idsorts first wins. A rule decides whatever its verdict: a more specificfreerule wins over a less specificdenyrule.Body limits of a rule (requests only): a body larger than
max_bodyturns the verdict into ASK,NeverAuto; a body larger thanbuffer_limitis DENYE_BODY_TOO_LARGEunless the verdict isfree(only afreerule may stream a body over the buffer limit). -
Anthropic domains. A host under
anthropic.com,claude.aiorclaude.comthat no rule decided: at the domain,api.anthropic.com,claude.ai,console.anthropic.comandstatsig.anthropic.comare FREE, any other host ASK (NeverAuto). For a request, see Claude API. -
CONNECTdefaults. A trusted host: FREE. Any other: ASK,NeverAuto, red ("opens host for reads AND writes"). -
Request defaults, by class:
Class When Verdict receive-packpath ends /git-receive-pack, or query hasservice=git-receive-packASK, red; the screen lists the refs pushed websocketUpgrade: websocketASK ("the whole session is a write") readGET,HEAD,OPTIONSwith an empty bodyFREE on a trusted host; else ASK (unknown host) messaginga messaging host ASK, red publisha registry host and PUT,PATCH,DELETE(any write on an upload host)ASK, red uploadbody over 64 KiB or of unknown length, or a multipart, octet-stream, image, video, audio, zip or tar type ASK writeanything else ASK Every default ASK is
NeverAuto. A non-read body larger thanbuffer_limitis DENYE_BODY_TOO_LARGE. -
Heuristics. With
heuristics = "enforce", a score of 3 or more turns any verdict but DENY into ASK,NeverAuto, red, with the reasons as warnings. With"shadow", the reasons are only logged. -
Shadow mode. With
mode = "shadow", see below.
After the decision #
- A FREE request whose body exceeds
buffer_limitis streamed only if a policy rule decided it; otherwise DENYE_BODY_TOO_LARGE. An ASK request whose body exceedsbuffer_limitcannot be held: DENYE_BODY_TOO_LARGE. - A FREE request goes out (through
--upstream, or dialled directly, refusing non-public addresses except for internal hosts). TheHostsent upstream is the host decided, never the client's. A FREE or approved request first gets the headers of its credential injections. - An ASK request waits in the gate's queue. No console:
E_NO_CONSOLEat once. Pasthold:E_PENDING(the item stays on the console). The human'snor the console's timeout:E_DENIEDorE_TIMEOUT.
With no policy received yet (the console never answered since the gate started), step 7 to 9 and 11 do nothing, nothing is trusted, and only the four Claude hosts and their allowlist are free.
Credential injection #
An injection lets the agent call an authenticated API without ever holding its credential: the secret stays in the gate's container, the agent sends its request without it, and the gate adds the header after the verdict, for one host and port only.
[[profiles.default.internal_hosts]]
host = "homeassistant"
ports = [8123]
read = true
[[profiles.default.inject]]
id = "home-assistant"
host = "homeassistant" # an exact host (an internal host may be single-label)
port = 8123 # required
header = "Authorization"
scheme = "Bearer" # optional: the value is "Bearer <secret>"
credential = "ha-token" # the secret's name in the gate's secrets file
container = "lea-ab12cd34ef" # optional: one container; empty: every one
methods = ["GET", "POST"] # optional: these methods only
paths = ["/api/*"] # optional: these paths only
note = "Home Assistant long-lived token"
| Key | Required | Meaning |
|---|---|---|
id |
yes | Unique among the injections. Same form as a rule id. |
host |
yes | An exact host name, lower-cased; no pattern, no *, no IP literal (name the host as an internal host). A spliced host is refused: it is never decrypted. |
port |
yes | 1-65535. Only requests to this port get the header. |
header |
yes | A header name. The gate's own headers are refused: Host, Content-Length, Content-Type, Content-Encoding, Transfer-Encoding, Connection, Keep-Alive, Te, Trailer, Upgrade, Expect, any Proxy-*, X-Lea-Source and any X-LockHTTP-*. |
scheme |
no | A word (Bearer, Token) put before the value with a space. |
credential |
yes | The name of the secret in the gate's secrets file: lower-case letters, digits, _, -, ., at most 32 characters, no run of more than 16 letters and digits (a name, not a pasted token). Never the value. |
container |
no | The container it applies to. A container's injection wins over an all-containers one of the same header. |
methods |
no | The methods that get the header (upper-cased); empty: every one. TRACE, TRACK and CONNECT are refused: they never get it, in any case of the method (trace too). |
paths |
no | The paths that get the header, each exact or a prefix when it ends with * (as a rule's path); empty: every one. A request outside them goes out without it (audited inject <id> skipped). So does any path that is not canonical, whatever the list (an allowlist): only RFC 3986 unreserved characters (A-Z a-z 0-9 - . _ ~) and / as is, escapes only of reserved characters other than / (%3A, %3B, %40, ...), and escaped bytes >= 0x80 only as valid shortest-form UTF-8 letters, marks or digits (caf%C3%A9); no segment made only of dots, even with a reserved suffix (.., ..., ..;x, ..%3f). Everything else goes out without the header: /api/../admin, %2e, %2F, %5c, %25, %20, %00 (..%20, ..%00), an escaped unreserved character (%41), overlong or invalid UTF-8 (%c0%ae), a fullwidth dot or slash, a literal ;, , or @, an invalid escape. |
note |
no | Free text for you. |
Two injections of the same header, host, port and container are refused.
The secrets file. The values live in the gate's secrets file
(lockhttp gate --secrets FILE, usage.md), in
the gate's container only, never in an agent's, nor in the policy, the
console or the hub:
{"version": 1, "secrets": {"ha-token": "eyJhbGciOi..."}}
It must be mode 0600 (refused if group- or world-readable). Each value
is at least 8 bytes of printable ASCII (inner spaces allowed): no control
character can split the request. It is reloaded when it changes; a
rotated-out value stays in the leak check until the gate restarts. A file
that does not load, or a secret it lacks, refuses every request the
injection covers (E_INJECT): the request never goes out without its
credential.
When the gate injects. After the verdict (FREE, or approved on the hub) and only on a request it can read and that does not cross the internet in clear text:
- a decrypted HTTPS request (the gate decrypts with
--ca-certand--signer, and the host is neither spliced nor internal); - a plain HTTP request to an internal host (a LAN API such as
http://homeassistant:8123).
A plain HTTP request to any other host, and a CONNECT decided at the
domain (an internal host on HTTPS, a spliced host, no decryption), get no
header; the audit says inject <id> skipped. An injection never changes
the verdict: the request is decided as the agent sent it, and the approval
screen says which header the gate will add, from which secret (by name).
A header of the same name the agent sent is replaced, never forwarded
(audited as a warning).
The secret never goes elsewhere. The header is added only to the
request decided for that host and port: the Host sent upstream is that
host, and the gate does not follow redirects (a 3xx goes back to the
agent, whose next request has no credential). The agent never holds the
value; if a request of the agent carries it anyway, in the URL, a header or
the body, as is, URL-escaped (in any case), in hex or in base64 (standard
or URL alphabet, at any alignment inside a longer blob), each also found
percent-decoded up to twice (any escape, in any case, + as a space or
not; a stray % does not stop the decoding), it is refused with
E_SECRET_EGRESS, whatever its host and whatever rule or grant would let
it pass. The names the agent writes are checked too, ignoring case, dots
and percent-escapes: the host of the URL and of the Host header, the
CONNECT authority and the header names. A value in one of them is refused
first (E_SECRET_EGRESS), before the floor, the hub's address check (which
resolves the name) and the hub, and the audit shows the host as redacted.
While the gate holds a secret, a host (with its port) longer than 263
bytes or a header name longer than 1024 bytes is refused unscanned
(E_SECRET_EGRESS, rule secret:unscannable).
While the gate holds any secret, it scans every byte it can read of what
the agent sends, accepting the same forms everywhere (the percent-decoding
above included):
- a body over the buffer limit is scanned as it streams; the request is
aborted before a whole value goes out (
E_SECRET_EGRESS); - a body with a
Content-Encodingofgzipordeflateis decoded to be scanned (up to 256 MiB decoded); any other encoding, or an encoded body over the buffer limit, is refused (E_SECRET_EGRESS, rulesecret:unscannable); - a WebSocket's frames from the agent are unmasked and scanned (across
frames); the frame that completes a value is not sent and the
connection closes. The gate removes the agent's
Sec-WebSocket-Extensions(no compression) and closes a WebSocket whose target chose one anyway; a frame over the buffer limit closes it too; - a
CONNECTtunnel decided at the domain is scanned as it streams, and closed on a value.
The secret does not come back. The rules below apply to every request
to a host and port that has an injection, whatever the injection's
container, methods and paths, and whether this request got the header or
not: a credential one request carried may be stored by the target and
served back on another path, or to another container. Such a request asks
for an unencoded, whole response (Accept-Encoding: identity; its
Range and If-Range are removed, since two ranges could join the halves
of a value each redacted alone) and loses its Max-Forwards. A partial
response (206, a Content-Range, multipart/byteranges) or one that
switches protocols is refused (E_INJECT). Every value the gate has
loaded (as is, URL-escaped, hex, base64, also percent-decoded as above) is overwritten with * in the
response's status line, header names and values, and body (the length
stays the same); its trailers are dropped. A gzip or deflate response
is decoded first and goes to the agent decoded; any other encoding is
refused (E_INJECT). TRACE and TRACK, in any case, to the host and port are refused
(E_METHOD, rule inject:trace), and no request to it may upgrade to a
WebSocket (E_INJECT). The audit says redacted the injected secret <name>.
What is not covered. A value the agent splits across requests,
frames or tunnel reads (each part goes out before the last one completes
it: in a WebSocket or a tunnel, the gate matches the whole value and closes
the connection, but the parts before it are gone), or encodes otherwise
(encrypted, compressed in an application format, reversed, any encoding
not listed above). A CONNECT tunnel the gate does not decrypt carries the
agent's TLS: its ciphertext cannot be scanned (decrypt it, or do not
splice, to get the scan). A target that transforms the credential before
reflecting it (hashed, re-encoded) is not redacted, nor one that serves a
part of it through its own API (an offset or a length in the query, a
substring): each response is redacted alone, so parts read in separate
responses are not matched. A value split over several hosts or requests'
host names, or written as an internationalized (punycode) name, is not
matched in the host.
Audit. A request that got a header is audited with
"injected": ["<id>:<Header>"]: the injection and the header's name,
never the value.
Review. Adding or changing an injection is a loosening (it waits for
:review); removing one tightens.
Claude API #
Once decrypted, a request to api.anthropic.com, claude.ai,
console.anthropic.com or statsig.anthropic.com that no rule decided:
- Credential. Every value of
Authorization(with or withoutBearer) andX-Api-Keycounts. Two or more: DENYE_SECRET_EGRESS. One whose sha256 is not in the caller's list ofcredentials.json(or keyproxy's list for a keyproxy request): DENYE_SECRET_EGRESS. With no--credentials, this check is off; with an unreadable file, every credential is refused. - OAuth (
/v1/oauth/on the three hosts;/oauth/onclaude.ai): FREE. POST /v1/messages,POST /v1/messages/count_tokensonapi.anthropic.com: FREE only if the body was held, is one JSON object with no duplicate key and no key equal tomcp_servers,toolsorcontainerbut for case, and has nomcp_servers, nocontainer, and no tool whosetypeis other than empty,custom, orweb_search,bash,text_editor,computer,memory(with or without a_YYYYMMDDversion suffix). Otherwise ASK, red,NeverAuto, with what was found./v1/files*onapi.anthropic.com: ASK,NeverAuto(red unless aGET).- Other reads: FREE. Other writes: ASK,
NeverAuto.
Rules come before this step: a rule for one of these hosts overrides the
allowlist (a host = "*" rule included).
Exfiltration heuristics #
Computed for requests (not at the domain) unless heuristics = "off";
threshold 3.
| Signal | Points |
|---|---|
| more than 6 labels in the host name | 2 |
| a host label over 40 characters, or of 20+ characters with entropy above 3.8 | 2 each |
| a query string over 512 bytes | 2 |
| more than 20 query parameters | 1 |
| a query value over 128 bytes, or of 32+ characters with high entropy or long base64/hex (not a digest or version) | 2 each |
| a high-entropy path segment | 1 |
an X-* header (but X-Amz-*, X-Api-Key, X-Request-Id) over 256 bytes or high-entropy |
2 each |
a User-Agent over 256 bytes |
2 |
| an email address, IBAN or card number in the URL | 2 |
Query parameters that carry signatures or tokens by design (presigned URL
and OAuth parameters such as X-Amz-Signature, sig, code, state,
token) are not scored.
Shadow mode #
With mode = "shadow", step 16 turns every ASK or DENY from steps 7-15
into FREE, and records what would have happened: the audit's decision is
shadow-ask or shadow-deny, and shadow holds the verdict. What stays
enforced:
- the floor (step 3) and the gate's own checks (steps 1, 2, 5, 17);
- secret matches and Claude credential mismatches (
E_SECRET_EGRESS); - the deny list (
E_RULE), at the domain and inside a decrypted tunnel.
Everything else passes, including the Claude API allowlist, messaging,
publishing and git pushes. Use it to measure what a policy would ask before
you enforce it; lockhttp doctor warns while it is on.
heuristics = "shadow" (the default) is independent: the score is
computed and logged (shadow_score in the audit) but changes no verdict.
Changing the policy: what counts as a loosening #
The console compares the file with the approved policy every 2 s. A change
that only tightens is applied at once. A change that loosens waits, as a
whole, until you confirm it with :review (a tightening made in the same
edit waits with it); meanwhile the last approved policy stays in force. A
file that does not parse is reported, and the approved policy stays in
force.
| Change | Loosens when |
|---|---|
production |
true → false |
mode |
→ shadow |
heuristics |
lowered (enforce → shadow → off) |
buffer_limit |
raised |
hold |
never |
connect_grant, idle_timeout, max_session |
raised |
trusted, splice, redirect_cdn |
an entry added |
deny |
an entry removed |
internal_hosts |
a host or port added, or read set (removing read tightens) |
| a rule added | unless its verdict is deny |
| a rule removed | unless its verdict is free (its requests fall back to another rule or the defaults) |
inject |
an entry added or changed (removing one tightens) |
| a rule changed | its verdict rises (deny < ask < auto < free); or it now matches something it did not (another host, a container scope removed or changed, methods added or emptied, a wider path, a larger max_body, a later or removed expires); or a deny, ask or auto rule now matches less (what it no longer matches falls back elsewhere) |
The comparison is conservative: a rule's host change, for example, counts
as a loosening even toward a narrower host. note changes are not
compared.
Examples #
Trusted reads of docs, and their subdomains:
trusted = ["docs.rs", "readthedocs.io", "*.readthedocs.io"]
A container-scoped rule: the gate asks lockhub and a human answers
(container names come from lea's sources.json). verdict = "auto" here
would ask the same way, so ask says what happens:
[[profiles.default.rules]]
id = "search-api"
container = "lea-ab12cd34ef"
host = "search.example.com"
methods = ["POST"]
path = "/v1/query"
verdict = "ask"
max_body = "16KiB"
expires = "2026-12-31"
Rules come before the trusted list: a rule without methods on a trusted
host decides its reads too. List the methods you mean.
An API write path that always asks, even on a trusted host, while the rest of the host follows the defaults:
[[profiles.default.rules]]
id = "gh-issues"
host = "api.github.com"
methods = ["POST", "PATCH"]
path = "/repos/acme/shop/issues*"
verdict = "ask"
note = "issue comments: always shown"
Deny a host for every container, except one:
[[profiles.default.rules]]
id = "no-uploads-host"
host = "transfer.example"
verdict = "deny"
[[profiles.default.rules]]
id = "uploads-for-ci"
container = "lea-ci0000000"
host = "transfer.example"
verdict = "ask"
A pinned client: decide the host at the domain even when decrypting:
splice = ["updates.pinned-vendor.example"]
An internal service on a private address, reads free, writes asked:
[[profiles.default.internal_hosts]]
host = "nexus.corp.example"
ports = [8081]
read = true
A trial run before enforcing:
mode = "shadow"
heuristics = "shadow"