19 Security
Status: draft M0. Normative for server, clients, admin console and operations.
The analysis (system boundaries, assets, attackers, STRIDE, threat register with residual risk) is in
../security/threat-model.md. This chapter defines the controls that
the threat model refers to. Every control has an ID (SEC-...) and at least one
test (section 14).
Permissions: 07-permissions.md. Identity and tokens: 08-identity.md.
Privacy: 20-privacy.md. Dev isolation:
21-dev-isolation.md. Quality gates and supply chain:
23-quality.md. Releases: 18-release.md.
1. Trust assumptions
| Assumption |
Consequence if it breaks |
| The server host and its operating system are trustworthy. |
Everything is open; the product cannot catch that. Hardening the host is the duty of the instance operator (16-operations.md). |
| The owner is trustworthy, but not omniscient. |
Owner mistakes are possible; hence preview, step-up, audit, invariants. DMs stay protected even from the owner (07, SR6). |
| Whoever has write access to the database can change everything. |
The hash chain and exported checkpoints make changes to the audit log detectable, not impossible (ADR 0021). |
| Clients, agent runtimes, apps and content are not trustworthy. |
The server checks every input and every action itself. |
| LLM output is not trustworthy. |
Prompt injection is not solved; the limits are permissions, tool classes and approval cards, not the model. |
2. Transport
| ID |
Control |
| SEC-TLS-1 |
The public listener (server.listen_addr) accepts plaintext HTTP only on loopback or from peers in server.trusted_proxies (16-operations.md) (reverse proxy on the same host or in the same container network). Every other plaintext connection is closed during connection setup, without reading or writing. The internal listener is exempt (R-SE-09). |
| SEC-TLS-2 |
Where the server terminates TLS itself (server.tls_cert_ref and server.tls_key_ref, both set or both empty; with TLS, server.public_url must be https): at least TLS 1.2, TLS 1.3 preferred, cipher suites from the Go standard library without a list of our own. Reason: a list of our own becomes outdated; the default selection is maintained along with Go [derived]. No redirect listener for plaintext. |
| SEC-TLS-3 |
HSTS with http.hsts_max_age on every response as soon as server.public_url uses https (also behind a TLS proxy): Strict-Transport-Security: max-age=<seconds>, without includeSubDomains (other subdomains belong to the instance operator) and without preload (not reversible). With an http URL (development on loopback) the header is absent. |
| SEC-TLS-4 |
Outgoing TLS connections always verify the certificate and the host name. InsecureSkipVerify is forbidden by lint. |
| SEC-TLS-5 |
PostgreSQL via Unix socket, with sslmode=verify-full, or in the internal Compose network internal without egress and without a host port with sslmode=disable (16-operations.md, section 4.1, rule 2). Virus scanner only via Unix socket (clamd has no authentication in its protocol; ClamAV recommends the local mode; https://raw.githubusercontent.com/Cisco-Talos/clamav/main/etc/clamd.conf.sample, retrieved 2026-10-10 [verified]). OTel Collector via loopback, TLS or a Compose network of the same host without a published port for receiving (telemetry carries no content and no secrets, Baseline B11 and SEC-LOG-6); every route over another network with TLS. |
| SEC-TLS-6 |
Host names with public certificates appear in Certificate Transparency logs (https://tailscale.com/kb/1153/enabling-https, retrieved 2026-10-10 [verified]). The operations documentation recommends neutral host names. |
| ID |
Responses |
Headers |
| SEC-HDR-1 |
all |
X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Strict-Transport-Security (SEC-TLS-3), no version information in Server |
| SEC-HDR-2 |
API (JSON) |
Cache-Control: no-store, Cross-Origin-Resource-Policy: same-origin, Content-Type: application/json or application/problem+json |
| SEC-HDR-3 |
console (HTML and assets) |
CSP according to section 5, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp, X-Frame-Options: DENY, Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(); also Cross-Origin-Resource-Policy: same-origin (no other origin may embed a console asset) and Reporting-Endpoints: csp="/api/v1/csp-reports" (M6-03) |
| SEC-HDR-4 |
file retrieval |
Content-Disposition: attachment with filename* (RFC 6266), except for the inline list from 12-files.md, section 9.3; Content-Security-Policy: default-src 'none'; sandbox; Cache-Control according to 12, section 9.3 (always private, no-store with a ticket) |
COOP and COEP make the console "cross-origin isolated". Flutter needs this for multi-threaded
rendering with WebAssembly; without the headers it runs single-threaded
(https://docs.flutter.dev/platform-integration/web/initialization, option
forceSingleThreadedSkwasm, retrieved 2026-10-10 [verified]). They only work because all assets
come from the instance's own origin (section 5).
4. CORS and WebSocket origin
| ID |
Control |
| SEC-CORS-1 |
The API sends no CORS allow headers. Native apps need no CORS; the console is same-origin. |
| SEC-CORS-2 |
Only exception: public, non-personal responses without login (instance.discovery.read, JWKS) with Access-Control-Allow-Origin: * and without credentials. |
| SEC-CORS-3 |
WebSocket upgrade: if an Origin header is present, it must equal the origin of the instance; otherwise 403 before the upgrade. Native clients send no foreign Origin. Reason: protection of the console session (cookie) against cross-site WebSocket hijacking. |
| SEC-CORS-4 |
State-changing requests of the console carry a dedicated mandatory header and a matching Origin (08, section 7.5). The header is X-Console-Request: 1; a request with the console cookie and without it or with another Origin gets 403 forbidden with reason csrf_check_failed (M6-01). |
5. Content Security Policy of the web console
The console is a Flutter web application (15-client-architecture.md,
ADR 0028). Goal: no script, no style, no font and no WebAssembly from foreign origins.
Content-Security-Policy:
default-src 'none';
script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'nonce-<random per response>';
img-src 'self' blob: data:;
font-src 'self';
connect-src 'self';
worker-src 'self' blob:;
manifest-src 'self';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
require-trusted-types-for 'script';
trusted-types flutter-js;
report-to csp
Reporting-Endpoints: csp="/api/v1/csp-reports" names the endpoint of report-to csp. The CSP
stands on every response below /console/ (index.html, assets, SPA fallback, redirect and 404 of
the console mount), never on /api/ (SEC-HDR-2). index.html has no <base> element, because
base-uri 'none' blocks it; the loader derives every URL from the URL of its own script
(docs/decisions.d/M6-03.md D-M6-03-2).
Rules:
- CanvasKit or skwasm and the fallback fonts are shipped with the console. The loader gets
canvasKitBaseUrl and fontFallbackBaseUrl pointing to the instance's own origin; otherwise the
default for fallback fonts would be https://fonts.gstatic.com/s/
(https://docs.flutter.dev/platform-integration/web/initialization, retrieved 2026-10-10 [verified]).
- The loader gets the option
nonce; according to the Flutter documentation it applies to scripts
and styles that the engine inserts itself (same source [verified]). The server generates the
nonce per response.
'wasm-unsafe-eval' is needed because the renderer compiles WebAssembly [derived]. No
'unsafe-eval', no 'unsafe-inline' for scripts.
- The names of the Trusted Types policies of the Flutter loader and the engine are not confirmed
by primary sources. The test SEC-T05 loads the console in a headless browser with an enforced
CSP and fails on every violation; the list in the repo follows this test, not this
documentation. Observed (M6-03, CanvasKit build, Chromium 156, Firefox 157, WebKit 27.2):
exactly
flutter-js, the policy of the loader for the script URLs it injects; the engine
creates none [verified by SEC-T05]. The one list is TrustedTypesPolicies in
services/server/internal/platform/httpserver/csp.go.
- In current browsers,
connect-src 'self' covers the WebSocket to the same host; SEC-T05 checks
that too. If it fails, wss://<host> is added. Observed (M6-03): connect-src 'self' covers
wss:// to the own origin in Chromium, Firefox and WebKit; no wss://<host> is needed
[verified by SEC-T05].
- Violations go to an endpoint of the instance (
report-to), with a rate limit and without content
in the log. The endpoint is submitCspReport (POST /api/v1/csp-reports, R-AZ-33,
rl.public_auth): Reporting API format and legacy report-uri format,
http.max_json_body_bytes, no storage; the log carries only the effective directive, the
disposition and the origin of the blocked URL (or a keyword such as inline), the metric
bothy.console.csp_violations counts the reports. Identical violations of one request share
one log record, and one request writes at most console.csp_report_max_records records, so
abuse within the rate limit raises a counter, not the log volume.
frame-src is in the CSP only if console.grafana_embed_url is set, and names exactly
its origin (frame-src https://grafana.example.com); otherwise default-src 'none' blocks every
iframe. The embedding is optional and off by default; its conditions (auth proxy,
allow_embedding, frame-ancestors, COEP and CORP at the proxy) are in
25-visualization.md, section 9.2 (SEC-VZ-2). frame-ancestors 'none'
and X-Frame-Options: DENY of the console itself stay unchanged.
| ID |
Control |
| SEC-IN-1 |
Every request is checked against the OpenAPI or AsyncAPI schema before a handler runs (06-api.md, ADR 0013). Schemas for writes have additionalProperties: false. |
| SEC-IN-2 |
JSON decoding rejects duplicate keys, invalid UTF-8 and nesting that is too deep, with 400 malformed_request (06-api.md, section 5; on the gateway close code 4002). Reason: client_payload_sha256 (03) and processing must not see different readings of the same request. |
| SEC-IN-3 |
Size limits before reading: http.max_json_body_bytes for JSON, http.max_header_bytes (both 02), sync.ws_max_frame_bytes (04), file.max_upload_bytes (03) as a stream limit. Time limits: http.read_header_timeout, http.idle_timeout (02). |
| SEC-IN-4 |
Display texts (display names, names of channels, spaces, groups, tags and templates, titles, product name of the branding, reasons of administrative actions) are checked for control characters and bidi control characters and rejected. File names are cleaned instead of rejected: control, bidi control and zero-width characters are removed as 12-files.md 6.2 states (M3-03). Message content stays unchanged; clients isolate foreign names when rendering (Unicode isolation). |
| SEC-IN-5 |
Database access only via sqlc with parameters. A lint forbids assembled SQL texts outside the generated code. |
| SEC-IN-6 |
Regular expressions only with the Go library regexp (linear run time). |
| SEC-IN-7 |
Patches to run documents only on allowed JSON Pointer paths (04, section 11.2). |
| SEC-IN-8 |
Component definitions (component.v1) and command options are checked with length and count limits from the schema. |
6.2 Rendering in the clients
| ID |
Control |
| SEC-OUT-1 |
Markdown without raw HTML. Links only with https, http, mailto or internal references; the target is shown with its domain on tap or hover. |
| SEC-OUT-2 |
Clients never load foreign resources from content automatically: no images via URL, no link preview, no favicons. Images only from files of the server. Reason: otherwise an agent or attacker could exfiltrate data via image URLs and learn the IP addresses of the readers (threat T-AI-06). |
| SEC-OUT-3 |
Messages from agents, apps, imports and the system are visibly distinguished (badge according to origin). Only the server creates system messages (kind = 'system'). |
| SEC-OUT-4 |
Approval cards show a header that the client builds from server fields (agent, run, required capability), separate from the agent's text. An agent cannot influence the header. |
| SEC-OUT-5 |
Error responses according to RFC 9457 without a stack trace and without internal details; with correlation_id. |
7. Upload hardening
| ID |
Control |
| SEC-UP-1 |
Size limited before storing (file.max_upload_bytes, 03); abort with 413 as soon as the limit is reached in the stream. |
| SEC-UP-2 |
The MIME type is detected by the server, never taken over from the client (03, files.mime_type). |
| SEC-UP-3 |
Inline rendering only for the inline list from 12-files.md, section 9.3: raster images that the server has sanitized or re-encoded according to 12, section 7.1, their thumbnails and the audio and video types named there. SVG, HTML, PDF and everything else only as an attachment (SEC-HDR-4). |
| SEC-UP-4 |
The image size is read from the file header before decoding; above file.image.max_pixels (12-files.md) no preview. Protection against decompression bombs. |
| SEC-UP-5 |
Parsers for untrusted formats run in a separate process with memory and time limits, never in the API process. The image previews and the text extraction (14-search.md section 5) are pure Go parsers that run only in the jobs of the worker with a time limit, never in a request of the API process (Baseline R-Objektspeicher). |
| SEC-UP-6 |
Virus scan as a separate service (ADR 0033). Files are served only when scan_status IN ('clean','skipped') (03). If the scanner is not ready, files stay pending (fail closed). infected: never servable, notification to the owner. |
| SEC-UP-7 |
Archives are never unpacked to storage in the server and never opened nested. The only exception: the text extraction reads single XML parts from OOXML and ODF containers in the memory of the worker, within the limits search.extract.* (14-search.md section 5.3). |
| SEC-UP-8 |
Object keys are random and contain no names (03). Original names only sanitized in Content-Disposition. |
| SEC-UP-9 |
A file is attached only to messages of its channel scope_channel_id (03). This way a file cannot cross space boundaries into the context of another agent. |
| SEC-UP-10 |
Location data and other personal metadata are always removed from stored images: for JPEG and PNG losslessly on upload (segment and chunk filter), thumbnails re-encoded (ruling L16, ADR 0047, 12-files.md section 7). |
| SEC-UP-11 |
Clients get no permanent object storage URLs; every retrieval goes through file.read (07, section 8.1; ADR 0048). |
Storage locations, preview variants, quotas and the scan service in detail: 12-files.md.
8. SSRF ban and outgoing connections
Baseline section 4: no server-side loading of foreign URLs.
| ID |
Control |
| SEC-NET-1 |
All outgoing HTTP connections go through an egress client in the server (platform/egress). A lint forbids http.DefaultClient, http.Get, http.Post, custom transports and dialers outside this package. For bothy healthcheck that package has a loopback client (loopback only, http only, no redirect). The only exception is the OTLP export to the collector: its target comes from the configuration of the instance operator (OTEL_EXPORTER_OTLP_*), never from data, and the exporters of the SDK choose their dialer themselves. |
| SEC-NET-2 |
The only allowed targets are: configured identity providers (the issuer and the endpoints of the same provider discovered from it), push services, webhook.allowed_hosts (03), object storage, transcription service and entries in egress.allowed_hosts. A consumer sets its targets when it creates its client (Options.Hosts of the egress client, PLAN M1-22); for an identity provider they are the host of the issuer and the hosts of token_endpoint and jwks_uri from its verified discovery document (08-identity.md, section 2.2). Everything else: rejection before the connection is set up, without a DNS query. Environment proxies (HTTP_PROXY, HTTPS_PROXY) do not apply, because otherwise the check would only see the proxy. http only for entries with allow_private. |
| SEC-NET-3 |
The IP is checked after DNS resolution during connection setup (not before), so that DNS rebinding does not work; the connection goes to the checked address, never to the name again. Always blocked, even with allow_private: unspecified addresses (0.0.0.0/8, ::), link-local including the metadata address of the cloud providers (169.254.0.0/16, fe80::/10; RFC 3927), multicast (224.0.0.0/4, ff00::/8), broadcast and reserved addresses (240.0.0.0/4), IETF protocol assignments (192.0.0.0/24), benchmarking (198.18.0.0/15), deprecated site-local (fec0::/10), discard-only (100::/64), IPv4-compatible addresses (::/96), NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4 (2002::/16), Teredo (2001::/32) and the metadata addresses of the cloud providers that lie inside private ranges (fd00:ec2::254, host .200 of 100.100.100.0/24; sources in ruling M1-22/15 of docs/DECISIONS.md); IPv4-mapped IPv6 addresses are unpacked before the check. Blocked except with a matching entry in egress.allowed_hosts with allow_private: loopback, private networks (RFC 1918), shared address space (RFC 6598, also used by overlay networks), unique local addresses (RFC 4193). Documentation ranges (RFC 5737, RFC 3849) are not blocked; they are not routed on the internet. |
| SEC-NET-4 |
No automatic following of redirects for webhooks; for OIDC only to allowed targets. |
| SEC-NET-5 |
Time and size limits per response (egress.timeout, egress.max_response_bytes); a response that is too large is an error, never silently truncated. Rejected attempts are counted by bothy.egress.blocked (signals.md) and logged with purpose, reason and host, never with path or content. |
| SEC-NET-6 |
Clients load no link previews (SEC-OUT-2). Agent tools with network access live in the runtime, outside the server; their limit is the tool class (07, section 2.11). |
9. Logs without secrets and without content
| ID |
Control |
| SEC-LOG-1 |
Structured logs (log/slog). Secrets have their own type in the code, which always writes [redacted] when output. |
| SEC-LOG-2 |
Headers are logged via an allow list, not via a deny list. Authorization, DPoP, Cookie, Set-Cookie, invocation tokens, X-Forwarded-* and identity headers of a proxy never appear. Reason: with a deny list, every new header slips through. |
| SEC-LOG-3 |
Paths are logged as route patterns (/api/v1/hooks/{webhook_id}/{token}), never raw; query strings never. |
| SEC-LOG-4 |
Never in the log: message content, file names, channel prompts, transcripts, search terms, display names, email addresses, push tokens. People appear only as an HMAC pseudonym (bothy.principal.pseudo_id, R-OB-03, privacy.md section 3), agents and apps with their handle; device IDs never. This applies to logs and spans, not to the audit log (20-privacy.md). |
| SEC-LOG-5 |
IP addresses only in the audit log and only pseudonymized (20-privacy.md, section 4.5), not in general logs. |
| SEC-LOG-6 |
Telemetry attributes via an allow list per signal (17-observability.md); no content (Baseline B11). |
| SEC-LOG-7 |
Crash reports from apps are sanitized before sending; content, tokens and names are not included. |
10. Secrets
All secrets come from the secret provider interface (ADR 0011). The database holds
only hashes, values encrypted with an external key or references (secret_ref, 03).
| Secret |
Location |
Rotation |
Private signing keys (access token, invocation token, outgoing webhooks, audit checkpoints, terminal session token with purpose terminal) |
generated by the server in the key store secrets.signing_key_dir (ADR 0066): Ed25519, kid = fingerprint according to RFC 7638, file <purpose>-<kid>.pem; public part in server_signing_keys |
States next, active, retiring, retired. Scheduled after signing_key.invocation_rotation_interval (invocation token, 09) or signing_key.access_token_rotation_interval (access token), job instance.key_rotation every signing_key.rotation_check_interval, system action signing_key.transition; webhook_out, audit_checkpoint and terminal only on request (signing_key.rotate with mode = scheduled); terminal stays retiring for terminal.session_token_ttl plus auth.clock_leeway and is never in the JWKS, the host service gets the keys over its control connection (24, section 5.3; docs/DECISIONS.md D-M2-39-14). A key stays retiring until the longest validity signed with it plus auth.clock_leeway has expired; then retired, and the private file is deleted. Emergency: signing_key.rotate with mode = emergency sets all keys of the purpose to retired immediately, because a leak of the store also exposes next and retiring. Processes pick up changes at the latest after signing_key.cache_ttl |
| Pepper for token hashes |
secret provider |
only after a compromise; then all tokens are issued anew, because hashes of unknown tokens cannot be converted |
| Key for TOTP secrets |
secret provider, totp_key_ref per row |
new key for new entries, old ones stay readable until re-keying |
| Database credentials |
secret provider or environment |
operations |
| OIDC client credentials |
secret_ref in identity_providers.config |
private_key_jwt preferred |
| Push credentials (APNs key, FCM service account, WNS client) |
secret_ref; accounts of the owner |
according to the requirements of the providers |
| Object storage credentials |
secret provider |
operations |
| Key for pseudonyms in the audit log and in telemetry |
secret provider |
according to 20-privacy.md |
| Key of the backups |
outside the instance, with the owner |
operations |
| LLM API keys, subscription credentials |
never in the server; with the adapter or in the runtime of the person (Baseline I5) |
instance operator |
Rules:
| ID |
Control |
| SEC-SEC-1 |
The file driver of the secret provider reads only files with mode 0400 or 0600 that belong to the service user; otherwise the start is aborted. |
| SEC-SEC-2 |
config print and diagnostic endpoints never show secrets, not even truncated. |
| SEC-SEC-3 |
Secrets never appear in events, audit details, telemetry, error messages or crash reports. |
| SEC-SEC-4 |
In the repository: gitleaks locally and in the gate, a dedicated rule for the token prefix from 08, section 12 (23-quality.md). |
| SEC-SEC-5 |
Files of the secret provider are outside the database backup or only encrypted in it (section 12). |
11. Controls for agents
| ID |
Control |
Location |
| SEC-AI-1 |
Every piece of content that the server delivers to an agent carries its origin: author, origin (human, agent, app, import, system), kind (message, file, transcript, quote) and channel. The channel prompt is a separate field. Adapters must mark foreign content as data, not as instructions; the content of files is always data. |
09-agents.md, ADR 0054 |
| SEC-AI-2 |
Tool class with configured and reported value; host only for owners or one's own dev environment. |
07, SR2 |
| SEC-AI-3 |
Acting-for binding: an agent in a run has at most the permissions of the triggering person. |
07, SR9 |
| SEC-AI-4 |
Components with required_capability = 'approve_actions' have restricted_to = 'run_starter' by default. |
09-agents.md |
| SEC-AI-5 |
Agents never trigger; agent messages have origin = 'agent'. |
07, SR4 |
| SEC-AI-6 |
No automatically loaded foreign resources in clients. |
SEC-OUT-2 |
| SEC-AI-7 |
Rate limits per agent (rl.agent_runtime) and triggers per person (rl.trigger); cost limits per agent. |
05; 10-backend-policy.md |
| SEC-AI-8 |
Channel prompts are no place for secrets. The console says so during editing. Reason: an agent can repeat its prompt in its answer. |
R-AZ-09 |
11a. Controls for the terminal
Normative together with 24-terminal.md; threats T-TM-* in
../security/threat-model.md, section 6.7.
| ID |
Control |
Location |
| SEC-TERM-1 |
Terminal content only end-to-end encrypted (Noise NK); the relay forwards binary frames unchanged and records only length, time and session ID. No content in log, audit, telemetry, event log, search, push or run context. |
24, sections 5 and 10 |
| SEC-TERM-2 |
Terminal session token valid exactly once, bound to sid in the prologue and to the ephemeral key of the device (cek); PTY only after the first valid transport message. |
24, section 5.3 |
| SEC-TERM-3 |
Step-up fresh per opening (passkey or step-up device key; in the console only a passkey with userVerification = required), consumed in the transaction of the opening. |
08, section 6.3 |
| SEC-TERM-4 |
Host service and sessions as separate users without administrator privileges; no session user can read the keys (HD8); none of these users in a group with root-level privileges (HD2, for example docker); start only after a passed doctor; keys 0600 or DPAPI; no credentials as environment variables of the service. A second live control connection with the same keys disables the host. |
24, sections 2.2, 3.4 and 4.2 |
| SEC-TERM-5 |
Permissions: target host only with terminal, only people and groups; SR10, SR11 (I6) and SR12; revocation ends sessions immediately. |
07, sections 2.12 and 5 |
| SEC-TERM-6 |
The renderer blocks OSC 52 (clipboard), opens hyperlinks (OSC 8) only after a click with a visible target and answers no queries of the window title or the clipboard. |
24, section 11.2 |
| SEC-TERM-7 |
Web console: the terminal runs under the same CSP (section 5) without exception; it renders into the canvas and creates no HTML from output; connect-src 'self' covers the stream. Trusted Types stays mandatory; the terminal widget needs no policy of its own [derived from the design of Flutter Web], SEC-T05 checks it with an open terminal. |
ADR 0070 |
| SEC-TERM-8 |
Key pinning per device with a hard warning on change; every re-pairing in the audit and as an event to all owners. In the web console the pinning does not protect against a malicious server, because the server delivers the code of the console; there only host_service.require_local_confirm helps against it. |
24, sections 3.3, 5.1 and 5.5 |
| SEC-TERM-9 |
Profiles only from the configuration of the host service; the client chooses name and index, never command lines or paths. |
24, section 11.1 |
| SEC-TERM-10 |
Server host only for owners, checked twice (server and host service). |
07, SR10 |
| SEC-TERM-11 |
Every session in its own process group, on Linux in its own cgroup, on Windows in a Job Object; its end ends the whole process tree. Subscription login (HD6) checked before every PTY and periodically, as an additional check to SEC-TERM-12. |
24, sections 6.5 and 7.4 |
| SEC-TERM-12 |
One session user per person and host, never shared, not even after the end of the assignment; without an assignment no terminal (SR12); the host service binds every session user to the first person (osu in the token). Load-bearing enforcement of "no foreign subscription login" (ruling T12). |
24, section 2.2; 07, SR12 |
| SEC-TERM-13 |
No shared locations between session users: no location with code, settings or logins is writable for one session user and readable or executable for another; working directory and CLAUDE_CONFIG_DIR of the profile claude per session user; no foreign chain files (CLAUDE.md, CLAUDE.local.md, AGENTS.md, .mcp.json, .claude, .git) in the directories above; programs including their directory chain owned by a trusted owner (Linux and macOS root, Windows SYSTEM, Administrators or NT SERVICE\TrustedInstaller), the profile claude with organization only with host_service.claude_bin and with personal with a CLAUDE_CONFIG_DIR in the home directory; HD11 checks every configured path after full resolution including the directory chain and chain files, "writable" includes creating entries, on Windows also at the drive root (ruling T13). |
24, section 2.2 item 6 and 7.4 |
11b. Controls for visualizations
Normative together with 25-visualization.md; threats T-VZ-* in
../security/threat-model.md, section 6.8.
| ID |
Control |
Location |
| SEC-VZ-1 |
No iframes and no WebViews with Grafana or other web content for statistics in iOS, Android, macOS and Windows. |
25, section 9.1 |
| SEC-VZ-2 |
Embedding in the console only with console.grafana_embed_url set, whose origin differs from the origin of the console (otherwise bothy config check rejects it, because allow-scripts allow-same-origin removes the sandbox when the origin is the same); then frame-src of exactly this origin, iframe with sandbox and referrerpolicy="no-referrer"; Grafana only behind an auth proxy with a sender list that sets frame-ancestors to the origin of the console. |
Section 5, rule 7; 25, section 9.2 |
| SEC-VZ-3 |
Live frames, timeline and stats_minutes without content and without people; tools only with names. |
25, sections 1 and 5.2 |
| SEC-VZ-4 |
Every live frame through the delivery mode of authorize with stats.read, filtered to visible channels; costs only with cost.read.aggregate. |
25, section 4 |
| SEC-VZ-5 |
Grafana plugin unsigned only with its own ID in allow_loading_unsigned_plugins, never with a wildcard or other IDs. |
25, section 8.3 |
12. Supply chain, backups and audit integrity
Supply chain (details and tools in 23-quality.md and
18-release.md; Baseline R-Qualität):
| ID |
Control |
| SEC-SC-1 |
Dependencies with lockfiles and checksums (Go go.sum, Dart pubspec.lock, Python lock with hashes, npm lock); updates via Renovate with a waiting period (ADR 0041); OSV-Scanner in the gate. |
| SEC-SC-2 |
GitHub Actions pinned by full commit SHA, permissions: contents: read as the default, no pull_request_target, zizmor and actionlint mandatory (Baseline R-Qualität). |
| SEC-SC-3 |
Only GitHub-hosted runners with a fixed label, no self-hosted runners: in a public repository such a runner would run code from foreign pull requests, and GitHub advises against it (https://docs.github.com/en/actions/reference/security/secure-use, retrieved 2026-10-10 [verified]). ADR 0039. |
| SEC-SC-4 |
Container base images pinned by digest; releases signed, with SBOM and provenance (ADR 0036). |
| SEC-SC-5 |
Tools with a known compromise only in a version proven to be safe (example Trivy, Baseline R-Qualität). |
Backups (requirements for 16-operations.md):
| ID |
Control |
| SEC-BAK-1 |
Backups are always encrypted; the key is not on the same host as the backup. |
| SEC-BAK-2 |
Backups contain no files of the secret provider in plaintext (SEC-SEC-5). |
| SEC-BAK-3 |
Every restore applies the deletion journal (20-privacy.md, section 4.6) and increases the permission version so that all caches rebuild. |
| SEC-BAK-4 |
After a restore, clients detect the jump back (resync_required with cursor_ahead, 04, section 7.3). |
| SEC-BAK-5 |
The restore is rehearsed regularly; the rehearsal is a test in the operations runbook, not just a text. |
Audit integrity (ADR 0021):
| ID |
Control |
| SEC-AUD-1 |
The application role has only INSERT and SELECT on audit_events; UPDATE and DELETE are blocked (03). |
| SEC-AUD-2 |
Every audit.checkpoint_interval, the job audit.chain_verify recomputes the segment since the last checkpoint and only then signs the next checkpoint (sequence, hash, time, previous checkpoint), which is stored at audit.checkpoint_sink outside the database; a broken chain is never signed (02-architecture.md, section 12.3). If no target is set, every result of audit.verify carries the warning checkpoint_sink_not_configured and every run of the job writes it to the log; the console shows it permanently based on this result. |
| SEC-AUD-3 |
audit.verify compares the recomputed chain with the checkpoints from the target, not from the database. |
| SEC-AUD-4 |
Canonical form of a row (input of hash, 03 audit_events): a JSON object with the column names except hash; seq as an integer; IDs and correlation_id as UUIDs in lowercase with hyphens; occurred_at as RFC 3339 in UTC with exactly three decimal places and Z (the recorder truncates the time to milliseconds before storing); prev_hash as hex in lowercase; details as stored; NULL columns are omitted. Serialized according to RFC 8785 (JCS). This form is exactly the API representation AuditEvent without hash; an export (audit.read) returns it unchanged so that it can be verified without the server. |
| SEC-AUD-5 |
Schema audit_details.v1: a flat object. Reserved fields: ip_hmac (64 hex characters), ip_key_id (UUID from audit_ip_keys), aggregate (count from 1, first_at, last_at), violations (for i1_violation: list of {code, agent_id, credential_holder_id, principal_ids}; for i6_violation: list of {code, host_id, credential_holder_id, principal_ids}, M2-40), reason (the reason that an administrative action with a mandatory reason requires, as the request states it; without control and bidi characters according to SEC-IN-4, at most audit.reason_max_chars characters, a longer reason results in 400 validation_failed with code too_long), os_user (name of a session user of a host matching ^[A-Za-z0-9_][A-Za-z0-9_.-]*$, never a path; with host.manage and host.connect, 24-terminal.md section 10.1), and the lists of session user names of host.connect (M2-43): hd6_users, hd9_users, hd10_users, hd11_users, session_users_added and session_users_removed, each a list of names in the form of os_user and nothing else; the length bound host.os_user_max_chars belongs to the validation of the report (24 3.5). The member source_url (value kind auditing.SourceURL, an absolute https URL with a host, without user info and fragment, for the source of a price row of price.manage, M2-22) is allowed too. All other fields: keys ^[a-z][a-z0-9_]*$, values only UUIDs, codes ^[a-z0-9][a-z0-9_.:-]*$, integers, booleans, times in the form of SEC-AUD-4 or lists of these. No free text, no null, no floating-point numbers, never content, names, email addresses, tokens or enrollment codes. A value outside these forms is a programming error that the recorder rejects. If an action needs further free text, its ticket extends the schema here and in the code. |
| SEC-AUD-6 |
Each process aggregates identical denials per audit.deny_aggregate_window (05, section 2.4); at most audit.deny_aggregate_max_keys open aggregations, beyond that the oldest one is written immediately. Residual risk: a hard crash loses the open aggregations of a window (threat-model.md). |
| SEC-AUD-7 |
Format of the checkpoints: see the table below. audit.verify reads checkpoints only from the storage target, never from audit_checkpoints, and checks the signature, instance_id and the chaining of each checkpoint. |
Format of the checkpoints (03, audit_checkpoints):
| Part |
Ruling |
| Payload |
{instance_id, seq, hash, occurred_at, previous_checkpoint_hash} according to RFC 8785 (JCS); seq, hash and occurred_at belong to the captured row of audit_events, time in the form of SEC-AUD-4; hash and previous_checkpoint_hash as hex in lowercase; previous_checkpoint_hash is the SHA-256 of the canonical payload of the previous checkpoint and is absent for the first one |
| Signature |
compact JWS with header {alg, kid, typ: "audit-checkpoint+json"}, key with purpose audit_checkpoint (ADR 0066); audit_checkpoints.signature stores the whole JWS as UTF-8; verification accepts only the algorithms from 03 server_signing_keys, never none |
Storage with file:// |
file <path>/<instance_id>/<seq with 20 digits, zero-padded>.jws with the JWS; directory 0700, file 0600; written once (M1-50): a temporary file in the same directory created with O_EXCL and synced, then a hard link to the name, which fails when the name exists (a rename would replace a file a concurrent writer just stored), then fsync of the directory; an existing file with different content is an error and is never overwritten; the directory <path> itself must exist, the server never creates it |
Storage with s3:// |
object [<prefix>/]<instance_id>/<seq with 20 digits, zero-padded>.jws with the JWS in the bucket of audit.checkpoint_sink (M7-22); written once with PutObject and If-None-Match: * after a read of the key: the same bytes count as stored, other bytes are an error and the stored object stays; the bucket has Object Lock with a default retention in mode COMPLIANCE of at least retention.audit (checked by bothy config check and the start), so an overwrite a storage lets through only adds a version; the server reads the oldest version of every key (backup-restore.md 8) |
13. Penetration test plan
13.1 Levels
| Level |
When |
Who |
Scope |
| P0 automatic |
every local gate and CI |
tools |
SAST (gosec, Semgrep for Go, Python, TypeScript; dart analyze and custom_lint for Dart), dependencies (OSV-Scanner), secrets (gitleaks), workflow analysis (zizmor, actionlint), schema-based fuzzing of the API against the OpenAPI document, Go fuzzing of the parsers (DPoP, JWS, Markdown, patches, uploads), permission test matrix and mandatory tests from 07 and 08, CSP test of the console |
| P1 internal red team |
end of every milestone, before the merge into main |
fresh, read-only reviewer plus attack tests on a test instance |
threat register (all entries with likelihood medium or high), diff against the system boundary diagram, checklist according to OWASP ASVS 5.0 level 2 for server and console (https://owasp.org/www-project-application-security-verification-standard/, retrieved 2026-10-10 [verified]: 5.0.0 is the current version), OWASP Top 10 for LLM Applications 2025 for agent paths (https://genai.owasp.org/llm-top-10/, retrieved 2026-10-10 [verified]) |
| P2 external test |
before the first productive use by a second person and before publication |
external service provider (costs: decision of the owner) |
permission bypass (IDOR, 404/403), tokens and DPoP, WebSocket, console (XSS, CSRF, clickjacking), uploads, SSRF, invocation tokens, prompt injection across space boundaries, apps and webhooks, mobile apps (storage, keys, transport), terminal (relay, host service, console) |
| P3 dev isolation |
before enabling each environment type |
red team |
breakout and exfiltration tests according to 21-dev-isolation.md, section 8 |
| P4 terminal |
at the end of M2, M4, M5 and M6 |
red team |
attacks from the threat model, section 6.7 (T-TM-*): replay and swapping at the relay, tokens, step-up, SR10 and I6, privilege escalation on the host, escape sequences, console with extensions; report before the merge |
13.2 Rules
- Tests run only against a dedicated test instance with synthetic data (people Alex, Sam,
Robin; domain
example.com), never against the production instance and never with real accounts of third parties.
- Before P2: threat model up to date, scope and rules in writing, an emergency contact named.
- Findings are classified by severity, tracked as tickets and tested again after the fix.
An open finding with severity "high" or "critical" blocks the publication.
- Every finding updates the threat register (maintenance rule in the threat model).
14. Tests
| ID |
Test |
Controls |
| SEC-T01 |
A plaintext connection from an untrusted peer is rejected; with the proxy header driver and a non-loopback listener the driver stays off, /readyz reports trusted_header_requires_loopback, and enabling the driver yields 409 |
SEC-TLS-1, 08 ID-T08 |
| SEC-T02 |
Lint: no InsecureSkipVerify, no http.DefaultClient, no raw SQL |
SEC-TLS-4, SEC-NET-1, SEC-IN-5 |
| SEC-T03 |
Headers of every response kind against an expected list (API, console, file, error) |
SEC-HDR-1 to 4 |
| SEC-T04 |
CORS: no Access-Control-Allow-* except at the public endpoints; WebSocket with a foreign Origin rejected |
SEC-CORS-1 to 4 |
| SEC-T05 |
Console in a headless browser with an enforced CSP: zero violations, no fetch from a foreign origin, WebSocket works. make console-e2e (tools/console-e2e, M6-03): Chromium with the full flow (password and passkey step-up, negative test with a report), Firefox and WebKit as smoke test; CI job console-e2e when the build context of the server image or the Compose stack of the test changes (docs/quality/gates.md, "Jobs a change needs") |
Section 5 |
| SEC-T06 |
Schema fuzzing of all routes: no 5xx except expected ones, no acceptance of unknown fields, duplicate JSON keys rejected |
SEC-IN-1, SEC-IN-2 |
| SEC-T07 |
Bidi and control characters in display names rejected |
SEC-IN-4 |
| SEC-T08 |
Uploads: wrong type declaration, SVG with a script, decompression bomb, size over the limit, scanner not ready, infected test file (EICAR) |
SEC-UP-1 to 7 |
| SEC-T09 |
Egress: one address from each blocked range in SEC-NET-3 (IPv4 and IPv6, including the metadata address), DNS name with a private answer, redirect to a private address: all rejected |
SEC-NET-1 to 5 |
| SEC-T10 |
Canary test: send defined canary values (token, message text, display name, email, IP; with their milestones file name, prompt, search term, push token, form value) through all paths; none appears in logs, traces, metrics, audit details, error responses or foreign events; principal IDs of people appear in logs and traces only as a pseudonym (SEC-LOG-4). The ticket that builds a new path for such a field extends the scenario (PLAN) |
SEC-LOG-1 to 7, SEC-SEC-3 |
| SEC-T11 |
Secret provider file with mode 0644 |
Startup abort, SEC-SEC-1 |
| SEC-T12 |
config print with secrets set |
no secrets in the output |
| SEC-T13 |
Client rendering: Markdown with HTML, javascript: link, image URL, link preview |
nothing executed, nothing loaded |
| SEC-T14 |
Approval card with agent text that imitates a system header |
header from server fields, text clearly separated |
| SEC-T15 |
Restore of a backup with a message deleted afterwards |
message stays deleted (deletion journal) |
| SEC-T16 |
Audit: change of a row through the maintenance role, rewriting the whole chain, UPDATE with the application role |
detected or refused, respectively (SEC-AUD-1 to 3) |
| SEC-T17 |
TLS handshake with a version below 1.2 against a listener with its own TLS |
rejected (SEC-TLS-2) |
| SEC-T18 |
Terminal: tests TM-T01 to TM-T29 from 24-terminal.md, section 14; SEC-T05 and SEC-T10 with an open terminal |
SEC-TERM-1 to 13 |
| SEC-T19 |
Console without and with console.grafana_embed_url: CSP without or with exactly one frame-src origin, respectively; with the origin of the console as the value, bothy config check fails; SEC-T05 with an embedded dashboard behind a test proxy; static check of the app for WebView packages; canaries from VZ-T07 |
SEC-VZ-1 to 5 |
15. Decisions of this chapter
| No. |
Decision |
Reason |
| R-SE-01 |
Plaintext only from loopback and trusted proxies. |
Compose and a reverse proxy work; direct plaintext from outside does not. |
| R-SE-02 |
Console without foreign origins, with nonce, Trusted Types and COOP/COEP. |
The console is the most valuable target in the browser; everything foreign is attack surface. |
| R-SE-03 |
Allow lists for log headers and telemetry attributes instead of block lists. |
Otherwise new fields are visible by default; that is the wrong error class. |
| R-SE-04 |
One egress client with an IP check at connection setup. |
SSRF protection in one place, effective against DNS rebinding as well. |
| R-SE-05 |
Clients load no foreign resources from contents. |
Closes data exfiltration through image URLs and tracking of the readers. |
| R-SE-06 |
Image metadata is always removed, without a switch. |
Location data in photos is a common, unnoticed leak; a switch to turn it off would be a trap with no benefit for a team chat. |
| R-SE-07 |
External penetration test before the second productive person and before publication. |
Study C: red team before the first real second person; a public product needs an independent review. |
| R-SE-08 |
TLS in the server only with both references (server.tls_cert_ref, server.tls_key_ref), without its own cipher list and without a plaintext redirect. |
An edge remains the normal case; without an edge the server should stand securely on its own. A redirect would accept plaintext from anyone. |
| R-SE-09 |
The internal listener has no filter by peer. |
It serves only health and stack-internal webhooks, evaluates no identity or forwarding headers and is never published on the host. A filter would force the instance operator to enter the whole Compose network in server.trusted_proxies; then forwarding headers of every container would also count at the public listener. |
| R-SE-10 |
Stricter block list for outgoing connections; allow_private never opens link-local, multicast or unspecified addresses. |
Known SSRF bypasses (mapped IPv6, NAT64, 6to4, Teredo) and metadata services of the clouds; an entry of the instance operator is meant to open an internal target, not the metadata. |
| R-SE-11 |
Audit check offline: the canonical form equals the API representation, fixed schema of the details without free text. |
An export can be checked without a server; free text would be a place for contents and secrets. |
| R-SE-12 |
Terminal stream encrypted end to end; the protection goal leaves out a malicious instance operator of the server, with pinning and local confirmation as a fallback. |
The server is trusted (section 1); the encryption protects against relays, logs, database dumps and processes that read along (ADR 0068). |
| R-SE-13 |
Grafana embedding only as an option in the console, never in apps; frame-src only with a set URL. |
With allow_embedding the clickjacking protection of Grafana falls away; the proxy replaces it with frame-ancestors. Apps need no web content (ADR 0069). |
16. Configuration values of this chapter
| Name |
Default |
Meaning |
http.hsts_max_age |
31536000s |
max-age for HSTS |
egress.allowed_hosts |
empty |
additional allowed targets, form host[:port][;allow_private] (configuration.md, section 2); duplicate entries are a load error |
egress.timeout |
15s |
time limit of an outgoing request |
egress.max_response_bytes |
1048576 |
largest response of an outgoing request |
audit.checkpoint_interval |
1h |
interval of the signed checkpoints of the audit log |
audit.checkpoint_sink |
empty |
storage target of the checkpoints outside the database: empty (warning, SEC-AUD-2), file://<absolute path> on storage outside the database volume, or s3://<bucket>[/<prefix>] (M7-22), an S3-compatible object store with a retention lock, the recommended target for production: a bucket in the S3 bucket naming rules, an optional prefix of segments of [a-z0-9-] joined by single /. bothy config check refuses every other form; for s3:// it and the server start read the Object Lock configuration of the bucket and fail unless Object Lock is enabled with a default retention in mode COMPLIANCE of at least retention.audit (GOVERNANCE fails) |
audit.checkpoint_s3_endpoint |
empty |
endpoint of the object store of an s3:// sink, an absolute http or https URL without path; its own endpoint, separate from file.s3.endpoint and the backup; http only with an egress.allowed_hosts entry with allow_private |
audit.checkpoint_s3_region |
us-east-1 |
region of the S3 signature of an s3:// sink |
audit.checkpoint_s3_use_path_style |
true |
path-style requests of an s3:// sink instead of <bucket>.<endpoint host> |
audit.checkpoint_s3_access_key_ref |
file:<secrets.dir>/audit_checkpoint_s3_access_key |
secret reference of the own access key ID of an s3:// sink, read only with an s3:// sink; supplied by the owner from a separate account (backup-restore.md 8) |
audit.checkpoint_s3_secret_key_ref |
file:<secrets.dir>/audit_checkpoint_s3_secret_key |
secret reference of the own secret access key of an s3:// sink, read only with an s3:// sink |
audit.reason_max_chars |
500 |
maximum length of a mandatory reason of an administrative action in Unicode characters (SEC-AUD-5) |
signing_key.cache_ttl |
30s |
longest time a process still accepts a retired key or does not know a new one |
signing_key.rotation_check_interval |
1m |
interval of the job instance.key_rotation |
signing_key.access_token_rotation_interval |
30d |
scheduled rotation of the key for access tokens |
signing_key.accept_lost_keys |
false |
consent that a start whose key store secrets.signing_key_dir holds the private file of none of the unretired keys retires them and creates fresh ones; without it that start ends with an error and changes no key (restore without the key store, disaster-recovery.md case B) |
Values of other chapters that this chapter refers to: server.trusted_proxies
(16-operations.md), http.max_json_body_bytes, http.max_header_bytes,
http.read_header_timeout, http.idle_timeout (02-architecture.md),
file.image.max_pixels (12-files.md), console.grafana_embed_url
(25-visualization.md). Every name is in exactly one
chapter table (Ruling L4).