Skip to content

Web UI Configuration

[web] configures the browser UI that cru web serves. Every field has a default, so the section is optional — cru web works with no configuration at all, serving the embedded frontend on http://localhost:3000.

Add it to ~/.config/crucible/config.toml.

[web]

FieldTypeDefaultDescription
portinteger3000Port to listen on
hoststring"127.0.0.1"Bind address. 0.0.0.0 exposes the UI to your network
static_dirstring(unset)Serve assets from this directory instead of the ones embedded in the binary
api_keystring(unset)Bearer token for non-localhost clients. Unset generates and persists one; "" disables auth entirely
remote_shellboolfalseLet authenticated non-localhost clients use the terminal routes
registration_rootsarray of strings[]Optional confinement for the web UI’s “add project” button. Empty allows any ordinary directory (the floor is the only gate); a non-empty list confines registration to it — see Project registration from the web UI
allowed_hostsarray of strings[]Extra Host authorities the server answers to. Empty derives them from the bind address and this machine’s own hostname — see Host validation
[web]
port = 3000
host = "127.0.0.1"

allowed_hosts is empty by default and rarely needs filling: a client on another machine may use any name once it holds the API key, and a loopback caller already gets the loopback spellings and this machine’s own hostname. What is left for it is a name a loopback caller uses — a reverse proxy on this same box. registration_roots is the opposite — empty allows any ordinary directory, and you set it only to restrict.

cru web overrides port, host, static_dir, and remote_shell from the command line:

Terminal window
cru web --port 8080
cru web --host 0.0.0.0
cru web --static-dir ./web/dist
cru web --remote-shell

--remote-shell is additive with the config value — passing the flag turns it on, but it cannot turn a configured remote_shell = true off.

Authentication

Localhost requests never need a key. Non-localhost requests must present one as a bearer token, or sign in once through the UI to get an HttpOnly session cookie.

The key resolves in this order:

  1. api_key in [web], if set to a non-empty string — used as-is.
  2. api_key = ""auth is disabled; every client is trusted.
  3. Otherwise ~/.config/crucible/api_key, read if it exists and is non-empty.
  4. Otherwise a random key is generated and written there (mode 0600 on Unix) on first start.
Terminal window
cru web key # print the current key
cru web key --rotate # generate a new one (fails if api_key is set in config)

--rotate only manages the generated key file. When api_key is set explicitly in the config, change it there instead.

The key is deliberately never embedded in the URLs cru web prints — query-string tokens leak through browser history, server logs, and referrer headers.

Signing in mints a session token — never the key itself — into a cookie marked HttpOnly, SameSite=Strict and Path=/. Secure is added only for a request that reached the browser over HTTPS, and unconditionally setting it would break the plain-HTTP LAN case outright: a browser silently refuses to store a Secure cookie delivered over http://, so signing in would appear to succeed and leave you signed out.

cru web serves plain HTTP itself — TLS is always terminated by something in front of it — so the only evidence of the browser’s scheme is X-Forwarded-Proto, and it is believed only when the connection came from this machine, which is the shape a terminating proxy has: it dials the local port. The same header from another machine is a client talking about itself and is ignored.

Through a chain of proxies the header arrives as a list, and the leftmost value is read — that is the browser’s own hop, by the convention X-Forwarded-For follows. So the ordinary https, http of browser → TLS edge → local proxy → here is correctly read as HTTPS. What is still refused is what is genuinely ambiguous: two separate X-Forwarded-Proto headers, or a value that is not text. Each refusal is logged (warn for a misconfiguration, debug for an ordinary plaintext request), because the fallback is a cookie that is missing Secure over HTTPS — a downgrade that is otherwise invisible. Run cru web with -v to see them.

Remote shell access

The terminal routes hand out a PTY, which is unrestricted shell access on the host. They are therefore loopback-only by default. Setting remote_shell = true lifts that restriction for authenticated clients only, and it is fail-closed: with no API key configured (including api_key = ""), remote_shell is ignored and the loopback restriction stays, with a warning logged at startup.

[web]
host = "0.0.0.0"
remote_shell = true # only takes effect because a key is in use

Host validation

Every request is checked against the set of authorities this server answers to, and the check is the outermost layer: it runs before CORS, before the body limit, and before everything in auth — the localhost bypass, the API key, the same-origin WebSocket shortcut. It covers every route, including the health endpoint and the static frontend bundle. A request whose Host is not one of them gets:

{ "error": { "code": 403, "message": "Request Host is not an address this server answers to" } }

This is the DNS-rebinding defence. A browser puts the authority it navigated to in Host and page script cannot override it, so an attacker page on evil.test — even one whose DNS record has been rebound to 127.0.0.1 — is stuck sending Host: evil.test. Refusing every authority that is not ours is what makes the loopback bypass safe.

What rebinding is for, though, is the loopback bypass — the rebound page runs in a browser on this machine, so its requests arrive from 127.0.0.1 and skip auth entirely, and the Host check is all that stands in the way. A request from another machine has no such shortcut: it presents the API key or it gets a 401. So the check is strict for loopback callers and relaxed for the rest, and the practical effect is the one you want:

Request arrives fromHost it may use
this machine (loopback)the loopback spellings, this machine’s own name, allowed_hosts — and nothing else
another machine, key configuredany name; the API key is the gate, not the name
another machine, api_key = ""the same strict list as loopback — with no key behind it, the list is the whole defence

That is what makes a LAN bind work by any FQDN that resolves to this box — node7, node7.lan, a tailnet name, a CNAME, a name only the phone’s resolver knows — with nothing to enumerate and nothing to configure. What a remote client with no key can still reach is what was never behind auth anyway: /health and the static bundle.

allowed_hosts remains for the cases that are loopback callers — most often a reverse proxy on this same machine forwarding a public name:

[web]
host = "0.0.0.0"
allowed_hosts = ["crucible.example.com"]

What is accepted

  • Always, whatever else is configured: localhost:<port>, 127.0.0.1:<port> and [::1]:<port> — the three spellings of the loopback you actually bound.
  • Whatever host says, on <port>:
    • host is a specific IP (192.168.1.10) → that IP on <port>.
    • host is a name → that name on <port>.
    • host is a wildcard (0.0.0.0 or ::) → any IP-literal Host on <port> is accepted. That is the LAN case: a machine’s reachable addresses cannot be enumerated up front, and an IP literal in Host cannot come from rebinding, which needs a name.
  • This machine’s own names, on <port> and bare, for any bind that is not loopback-only: the system hostname, plus <hostname>.local when the hostname is a bare label. This is what lets the operator’s own browser use http://node7:3000 even when that name resolves to loopback, and it is what cru web prints on startup.
  • Every entry in allowed_hosts.
  • Any name at all, when the request came from another machine and an API key is configured — see the table above. This is the rule that makes arbitrary FQDNs work; the three above are what a loopback caller is held to.

How an entry is matched

  • An entry without a port matches both bare and with <port> appended. "crucible.example.com" accepts Host: crucible.example.com (a proxy terminating on 80/443 forwards the public name with no port) and Host: crucible.example.com:3000.
  • An entry with a port matches that port only. "old.example:8443" accepts Host: old.example:8443 and nothing else — not old.example, not old.example:3000.
  • Ports are always compared numerically, so :03000 and :3000 are the same port.
  • Matching is case-insensitive, and one trailing dot is stripped from the host (evil.test. and evil.test are the same name to a resolver, so they are the same string here).
  • IPv6 must be bracketed, and is canonicalised: [0:0:0:0:0:0:0:1] and [::1] are the same entry. An unbracketed IPv6 address is not a legal HTTP authority and is rejected.
  • An entry beginning with a dot is a suffix. ".example.com" accepts the apex example.com and exactly one label under it — app.example.com — and nothing deeper: a.b.example.com is refused. That is Rails’ rule rather than Django’s any-depth one, because a wildcard at arbitrary depth inherits every delegated subtree under the apex, and one dangling NS record down there hands an attacker A-record control of a name inside it — which is exactly what rebinding needs. List a.b.example.com explicitly if you want it. The dot is part of the comparison, so evilexample.com and sub-example.com are not matches. Ports work as above: ".example.com" also accepts app.example.com:<port>, and ".example.com:8443" accepts that port only.
  • There is no glob syntax. "*" and "*.example.com" are not patterns — * is not a character a hostname may contain. Write ".example.com".
  • An empty list means “derive from the bind address” — i.e. exactly the two bullets above it. It does not mean “allow anything”.

What is refused

Anything the server cannot resolve to exactly one unambiguous authority is a 403, not a guess:

  • no Host header at all;
  • two Host headers;
  • a Host that disagrees with an absolute-form request target or an HTTP/2 :authority;
  • an authority carrying userinfo, a path, a scheme, whitespace, non-ASCII, percent-escapes, port 0, or an out-of-range port.

An entry the server will not start with

A bad allowed_hosts entry fails startup, with the entry named in the error. It used to be dropped with one tracing::warn! line, which meant a *.example.com entry produced a 403 from an allow-list that read as configured and behaved as empty. Refused entries:

  • anything that is not a host, host:port, or .suffix — a glob (*.example.com), a URL, a path, whitespace, non-ASCII, port 0;
  • a bare ., or a dot with nothing usable after it (..example.com, .:3000);
  • an address as a suffix (.192.168.0.1) — suffix matching is for names, so this could never match anything;
  • a public suffix: any single-label suffix (.com, .io, .local, .internal), plus a best-effort list of multi-label shared apexes — .co.uk, .github.io, .trycloudflare.com, .ngrok.io and the like. Vite’s docs put it plainly: “you should never add Top-Level Domains like .com to the list.” A domain you control under one of them (.crucible.co.uk) is fine. The list is hand-written rather than the Public Suffix List, so an apex missing from it is accepted — it narrows the mistake, it does not close the class;
  • any suffix entry inside an undelegated namespace.local, .internal, .home.arpa, .lan, .corp, .intranet — refused at every depth, so .node7.local is out, not just .local. Nobody owns a subtree there: whoever answers the query owns the name while they answer it, so a suffix entry admits any name any peer on the network cares to claim. Name the machine exactly instead (node7.local, no leading dot), which is one authority rather than a namespace.

Project registration from the web UI

POST /api/project/register — the web UI’s “add project” button — registers any ordinary directory you point it at, including the repository you are working in. A registered project root is also a read scope for /api/file/raw, so a small floor is always refused (see below); everything else is allowed by default.

registration_roots is empty by default, which means the floor is the only gate. Set it only if you want to confine registration further — with a non-empty list, a new root must also be inside one of its entries:

[web]
registration_roots = ["~/work/repos"]

A leading ~/ expands to your home directory. Entries are canonicalised before use, so a symlink cannot present a name inside a root for a target outside it. An entry that does not resolve, or that is itself a floor-refused root, is dropped with a warning; if a non-empty list has no valid entries left, registration is refused entirely (a misconfigured allowlist fails closed rather than falling back to the floor).

The floor holds for every caller — CLI, TUI, RPC and web alike — and is:

  • the filesystem root /;
  • your home directory itself, or any ancestor of it (every credential you own lives under it), so registration_roots = ["~"] is refused while ["~/work"] is fine;
  • /home and /Users, which hold every user’s home;
  • a credential store (.ssh, .gnupg, .aws, …) or the user’s config/state tree (.config, .local) — the web caller is untrusted, so these are refused even though a local cru may register them;
  • anything under /etc, /proc, /sys, /dev, /boot, /root or /run.

So registration_roots = ["/"] does not re-open the door; it is dropped.

The daemon resolves a registration inside a git repo up to the repo root, which can land above the directory you asked for. If that escapes the floor (or an active registration_roots restriction) the registration is rolled back.

Endpoint validation

A session created through the web API may name a custom provider endpoint (a self-hosted model). The web layer validates it first, because the server dialing a URL the browser chose is an SSRF primitive: the browser is a confused deputy for everything the server can reach.

The rule is an allow-list, not a deny-list. An endpoint is accepted only if:

  • the scheme is http or https; and
  • every address its host maps to is a globally routable unicast address.

For IPv4 that refuses loopback, the RFC 1918 private ranges, link-local 169.254.0.0/16 (which is where the cloud metadata address 169.254.169.254 lives), CGNAT 100.64.0.0/10, 0.0.0.0/8, 192.0.0.0/24, benchmarking 198.18.0.0/15, reserved 240.0.0.0/4, multicast, broadcast and the unspecified address. For IPv6 only global unicast 2000::/3 is accepted at all, minus the documentation prefix 2001:db8::/32 — so ::1, unique-local fc00::/7, link-local fe80::/10, multicast and every other reserved prefix are refused without having to be enumerated.

Hostnames are resolved and judged on all their answers, so one internal record in an otherwise public answer set refuses the whole endpoint. An unresolvable host is refused too — an unknown host is not a safe host. IPv6 forms that encode an IPv4 destination (v4-mapped, v4-compatible, v4-translated, 6to4, NAT64) are judged as that IPv4 address, and so are alternative spellings the URL parser normalises (http://2130706433, http://0x7f.1 are both 127.0.0.1).

Loopback endpoints

A local Ollama on http://localhost:11434 is loopback, and so would be refused by the rule above. It is the product’s headline local-LLM path, so it gets an exception — decided by the bind address, with no configuration (the escape hatch below can only widen this):

Effective bind ([web] host, or cru web --host)Loopback endpoints
127.0.0.1 (the default), any 127.x.x.x, ::1, localhostallowed
0.0.0.0, ::refused
a LAN address, or any other namerefused

The reasoning is who the browser is. On a loopback bind the only browser that can reach this server is already on this machine, so pointing it at this machine’s loopback grants it nothing it did not already have. On a LAN or public bind the browser is a confused deputy: the server’s own loopback services are exactly what that browser cannot reach on its own.

0.0.0.0 and :: are the unspecified address, not loopback, so a wildcard bind refuses. A bind host that is neither localhost nor a parseable IP is treated as reachable from elsewhere and refuses too. The localhost match is case-insensitive, and brackets around an IPv6 bind are ignored ([::1] is ::1).

A refusal reads:

Endpoint must not target a private/internal address: localhost → 127.0.0.1 (loopback
endpoints are allowed only on a loopback bind, or with
CRUCIBLE_WEB_ALLOW_LOOPBACK_ENDPOINTS=1)

The parenthetical appears only when the target actually is loopback — including the IPv4 mapped and embedded spellings, so http://[::ffff:127.0.0.1] gets it too. The other internal ranges have no opt-in and get the bare message.

The escape hatch

CRUCIBLE_WEB_ALLOW_LOOPBACK_ENDPOINTS is for the one case the bind rule gets wrong on purpose: an operator who deliberately exposes cru web on a LAN and still wants sessions pointed at the server’s own Ollama.

Terminal window
CRUCIBLE_WEB_ALLOW_LOOPBACK_ENDPOINTS=1 cru web --host 0.0.0.0
  • It only ever adds permission. On a loopback bind it is redundant.
  • The value must be exactly 1 or true (case-insensitive, surrounding whitespace ignored). Anything else — yes, on, 0, empty, unset — is off.
  • It is an environment variable on the cru web process. There is no config-file equivalent, deliberately: it should be a decision someone makes at launch, not one that outlives the reason for it.
  • It unlocks loopback only. Private, CGNAT, link-local and the metadata address stay refused with it set; it is not a general “allow internal addresses” switch.

Setting it alongside a LAN bind means anyone on that network can steer the server at its own loopback services. That is the trade you are making.

Two further limits worth knowing:

  • This check happens at validation time, not at connect time. A short-TTL DNS record can answer with a public address here and an internal one when the provider actually connects. It raises the cost of the attack; it is not a boundary.
  • It lives in the web layer only. The same endpoint reaches the daemon unvalidated from the TUI or a direct RPC client, which are already local-trust paths.

Webhooks

POST /api/webhook/{name} turns a request into a webhook:received event, which reaches both subscribed clients and any Lua handler registered for it — cru.on("webhook:received", { pattern = "ci" }, fn), see Event Hooks. It sits inside the bearer-auth layer, but that layer waves loopback callers through — so on the machine running cru web, any page you visit could otherwise reach it cross-origin with no credential. Every delivery therefore carries its own signature, and a webhook with no configured secret is refused.

Reachability, stated plainly. Because the route is inside bearer auth, a caller that is not on loopback must present the web API key as well as a valid signature. A service out on the internet will send neither, so pointing GitHub or Stripe straight at the port gets a 401 from the auth layer before the signature is ever read. Today the ingress is for senders that terminate on the host: a script on the same machine, a reverse proxy that adds the bearer header, or a tunnel whose far end lands on loopback. Whether the route should instead sit outside bearer auth with the HMAC as its only credential is an open decision, not an oversight — it would turn an authenticated route into an unauthenticated one for every existing install, which is not a change to make as a side effect of something else.

The ingress is closed until you write a secrets file. Out of the box every delivery gets a 401 Missing or invalid webhook signature. That one message covers every reason — unknown name, missing secret, bad signature, stale timestamp — so the endpoint is not an oracle for which webhooks exist. The real reason is in the server log.

Secrets file

~/.config/crucible/webhooks.toml, one entry per webhook. It is not part of config.toml, and it is read once, when the server starts — restart cru web after editing it.

~/.config/crucible/webhooks.toml
[webhooks.ci]
secret = "at-least-16-bytes-of-secret"
[webhooks.deploy]
secret = "a-different-at-least-16-byte-secret"

The easiest way to mint an entry is the CLI, which writes [webhooks.<name>] into the file at mode 0600, leaving other entries alone:

Terminal window
cru web webhook ci # mint a new secret for /api/webhook/ci
cru web webhook ci --rotate # replace an existing one; without --rotate an existing
# webhook is refused rather than silently invalidating
# its sender

Or by hand, with 32 random bytes:

Terminal window
mkdir -p ~/.config/crucible
printf '[webhooks.ci]\nsecret = "%s"\n' "$(openssl rand -hex 32)" >> ~/.config/crucible/webhooks.toml
chmod 600 ~/.config/crucible/webhooks.toml

A webhook name is a URL path segment and a TOML key at the same time, so keep it to ASCII letters, digits, - and _.

Rules, all fail-closed:

  • A secret shorter than 16 bytes is dropped with a warning; that webhook then refuses everything. A captured delivery lets an attacker brute-force a short secret offline.
  • Two webhooks sharing one secret are both dropped. Nothing in the signature names the webhook, so a delivery aimed at one would authenticate the other. Give each its own.
  • A missing, unreadable or malformed secrets file leaves the ingress closed. Failing to read the secrets never means “let it through”.

Signature scheme

Deliveries must be Content-Type: application/json. That is not cosmetic: text/plain, application/x-www-form-urlencoded and multipart/form-data are CORS-safelisted, so a cross-origin fetch using one of them is a simple request the browser sends with no preflight and no chance to refuse. Requiring JSON forces the preflight, which the CORS layer answers only for the app’s own origins. Defence in depth behind the signature, never instead of it.

Three signature headers are accepted, so an off-the-shelf sender works unmodified. They are tried in the table’s order — X-Crucible-Signature, then Stripe-Signature, then X-Hub-Signature-256 — and a delivery that carries a timestamped header is never downgraded to the body-only check — otherwise a caller who could send either would get to pick the weaker one.

HeaderValueSigned materialReplay
X-Crucible-Signaturet=<unix>,v1=<hex><t> . raw bodytimestamp + memory
Stripe-Signaturet=<unix>,v1=<hex><t> . raw bodytimestamp + memory
X-Hub-Signature-256sha256=<hex>raw body alonememory only

Common to all three: HMAC-SHA256 under that webhook’s secret, over the raw body bytes. Sign the bytes you send — a re-encoded body (pretty-printed JSON, a reserialised value) produces a different signature. Hex is case-insensitive, and the tag must be exactly 32 bytes (64 hex characters).

Timestamped (Crucible’s and Stripe’s — the shape to prefer, and the same wire format, so a real Stripe endpoint verifies against its own whsec_… with nothing bespoke in between):

  • t is signed as the literal text you send — reformatting it (0077) hashes different bytes than the sender hashed.
  • t is also checked against the server clock and must be within 300 seconds either way. The tolerance is symmetric, so a sender whose clock runs fast is not permanently rejected.
  • Fields other than t and v1 are ignored, not refused — Stripe sends v0= alongside v1=, and a verifier that rejects every field it has not seen cannot survive its own senders adding one. A repeated t or v1 is still refused: a verifier that picks one of two candidate signatures is a verifier an attacker gets to aim.

Body-only (GitHub’s) is accepted so a GitHub webhook works as shipped, but it is the weaker scheme and worth understanding before you rely on it. Nothing in the signature says when the delivery was sent, so there is no freshness check at all — only the replay memory below stands between a captured delivery and a replay, and once its tag ages out the identical delivery verifies again. sha1= and unprefixed values are refused; the algorithm prefix is required.

Replay memory, common to all three: a signature that has already verified is remembered and refused if it comes back. Remembered entries are dropped after the 300-second window, and the store holds at most 4096 of them, evicting oldest-first. For a timestamped delivery that does not matter — the clock check refuses it once it leaves the window anyway. For a body-only delivery the memory is the entire replay defence, so its real window is whichever comes first: 300 seconds, or 4096 further deliveries pushing the tag out. Under a chatty sender that can be well under 300 seconds.

Signing a delivery from a shell:

Terminal window
body='{"event":"push"}'
t=$(date +%s)
sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -X POST http://localhost:3000/api/webhook/ci \
-H 'Content-Type: application/json' \
-H "X-Crucible-Signature: t=$t,v1=$sig" \
--data-raw "$body"

Pointing GitHub at it needs no code: set the webhook’s Payload URL to https://…/api/webhook/<name>, paste the same secret into GitHub’s Secret field, and set Content type to application/json. GitHub’s default there is application/x-www-form-urlencoded, which this endpoint refuses with a 415 Unsupported Media Type — if deliveries fail with a 415 rather than a 401, that setting is why.

The delivery’s headers go onto the plugin event stream verbatim, minus the credentials: Authorization, Cookie, Proxy-Authorization and all three signature headers are stripped first.

CORS

The server allows its own origins automatically: http://<host>:<port>, http://127.0.0.1:<port>, and http://localhost:<port> (plus the Vite dev server on http://localhost:5273, or CRUCIBLE_WEB_PORT if set, in debug builds). Add more with a comma-separated CRUCIBLE_CORS_ORIGINS:

Terminal window
CRUCIBLE_CORS_ORIGINS="https://notes.example.com,http://192.168.1.10:3000" cru web

CORS is a different question from host validation, and the two lists are deliberately not mirrored. allowed_hosts names authorities the server answers to; CORS names page origins allowed to talk to it cross-origin. A deployment behind a proxy is same-origin from the browser’s point of view, so CORS is never consulted for it.

Security headers

Every response carries these, set only if the route did not already set its own — so /api/file/raw, which sandboxes the documents it refuses to serve inline, keeps its stricter policy.

HeaderValue
X-Content-Type-Optionsnosniff
Referrer-Policyno-referrer — the app’s URLs name your machine and ports; nothing it links out to needs them
Content-Security-Policysee below

The CSP is written from what the frontend actually does. The parts worth knowing:

  • script-src 'self' 'wasm-unsafe-eval' — only the app’s own bundle executes. No 'unsafe-inline', no 'unsafe-eval'. WASM is allowed because shiki’s regex engine and the opt-in local Whisper model both compile it.
  • frame-ancestors 'none' — a loopback instance is authenticated without a cookie, and so would be clickjackable if framable.
  • style-src 'self' 'unsafe-inline' — unavoidable today. CodeMirror, xterm, mermaid and katex all inject styles at runtime, and a nonce cannot reach them.
  • connect-src 'self' https:'self' is what lets the terminal dial its own WebSocket. The policy names no authority, deliberately: a proxied deployment on https://crucible.example.com gets its own wss:// socket from the same 'self', so there is nothing here that can fall out of step with allowed_hosts. The https: is for the optional transcription model download.
  • img-src/media-src/frame-src stay open to remote schemes because markdown renders web images and a canvas link node embeds pages in a sandboxed iframe.

None of this is configurable.

Serving kiln files

/api/file/raw is the one route that hands back bytes an agent may have written, and it is same-origin with the API — so a file the browser parses as a document there could fetch('/api/shell/exec') with your credentials already applied. It is therefore an allowlist of what may be rendered inline:

Content typeServed as
image/*, audio/*, video/*itself — a media decoder, no scripting surface
image/svg+xmlitself, but under Content-Security-Policy: sandbox
application/pdfitself — the viewer’s scripting has no access to the embedding page
text/plaintext/plain; charset=utf-8, charset pinned so the browser cannot pick one out of the bytes
everything else, including files with no extensionapplication/octet-stream + Content-Disposition: attachment + sandbox CSP

The practical consequence: clicking an .html file in your kiln downloads it rather than opening it. That is deliberate, not a bug — rendering it would put agent-written script on the app’s own origin. nosniff is set by the route itself rather than left to the global layer, because a declared content type is only binding with it.

The sandbox CSP (sandbox; frame-ancestors 'none', no allow-* tokens) puts the document in a unique opaque origin, so even if something renders those bytes anyway — a plugin, an external viewer, a browser mishandling the disposition — its script cannot reach the API, the session cookie, or the app’s DOM. Download filenames are reduced to [A-Za-z0-9._-], since the name is attacker-chosen and could otherwise inject a second header.

Note which rows don’t get that sandbox: it covers the download path and SVG only. PDFs and decoded media are served under the app’s own policy, deliberately — sandbox breaks Chrome’s PDF viewer, and a canvas file card embeds one. The accepted risk is that a PDF from your kiln is rendered by the browser’s viewer with the app’s policy applied; the judgement is that the viewer gives a PDF’s own scripting no DOM, cookie or same-origin fetch access to the embedding page. If that assumption ever fails, this is the row to revisit.

[web] vs [server]

These are different sections. [web] is the browser UI above. [server] holds daemon-side settings (auto_archive_hours) plus several TLS and request-limit fields reserved for future use and not yet wired to any behaviour. Configuring the web UI under [server] has no effect.

See Also

  • acp — ACP agent configuration
  • permissions — tool permission rules, which apply to web sessions too
  • Workflow Authoring — the webhook workflow trigger these deliveries feed
  • Index — full CLI reference