Security architecture
Heinzel Pro is built so that the model can propose, but only people and code decide. This page describes the controls and their limits.
Controls for an audit
Administration, Controls maps the product's safeguards (approval, the four-eyes rule, roles, sign-in, the audit log, key handling, the change record) to controls of ISO/IEC 27001:2022 Annex A, to the series of the SOC 2 common criteria and to Bausteine of the BSI IT-Grundschutz-Kompendium, each with its state in the tenant and a link to its evidence. It is a map for the conversation with an auditor, not a certificate.
Trust boundaries
- The model provider receives the system prompt, the session, and command output scrubbed of secrets. The provider can be a server in your own network (Ollama, vLLM), in which case nothing leaves the intranet. It never receives SSH keys, the vault key, user credentials or the audit log.
- Servers receive exactly the commands shown in the UI, over SSH with a per-server key that Heinzel Pro generated and keeps encrypted.
- The browser receives only what the user's role allows.
What leaves the installation
An installation opens connections to the places below and to no other. Each is one you entered, with one exception, the rule set. There is no telemetry, no usage figure and no license check. The list is complete, and a test in the product's suite fails when the code opens a connection that this list does not name.
- Model provider. The address of each provider you add, by default
api.anthropic.comorapi.openai.com, or a server in your own network. One reached through a VPN connection or a server is dialled inside that connection or forwarded over that server's SSH, never from the installation's own network. It receives what Trust boundaries says, whenever a session works. - Your servers. SSH to the hosts you add, on the port you give them.
- VPN connections. WireGuard to the peer in the block you pasted. A Tailscale connection talks to Tailscale's coordination server and relays, or to the Headscale server you name; the helper is started with its log upload switched off.
- Mail. The mail server or mail service you configured. Microsoft 365
without SMTP signs in at
login.microsoftonline.comand sends throughgraph.microsoft.com. - Endpoints you enter. The team's webhook, the collector the audit log is shipped to, the monitoring that takes annotations. Each stays silent until a tenant enters an address.
- Single sign-on. The OpenID Connect issuer you configure, when somebody signs in through it.
- Rule set.
api.github.comandgithub.com, only when an administrator of the installation presses Check or Update under Settings, Rule set. The request reads the public Heinzel repository without an account and carries nothing about the installation; GitHub sees its address. Upload the archive instead, and this connection is never made. The copy of the rule set an image carries was fetched the same way when the image was built (mix heinzel.rules.sync), by the developer, never by an installation. - Model catalog. Never fetched. The catalog of models an image carries,
with the days their vendor retires them, was compared with Anthropic's
deprecation page on
platform.claude.comwhen it was made (mix heinzel.catalog check), by the developer, never by an installation. A newer catalog between releases is pasted by hand.
Docker itself fetches the image from ghcr.io when you pull it.
Command path
- The model calls
run_commandwith a server, a command and a one-sentence rationale. - The classifier sorts the command into a risk class: read-only, write,
package, service, firewall, reboot, destructive, blocked. It parses pipes,
&&,;, subshells,sudoandsh -cand fails closed: anything it cannot read is at least "write". The class is decided in code and shown to the operator; the model cannot influence it. - Policy: blocked commands never run. Blocked servers deny everything. Read-only servers deny anything that writes. Unattended runs skip anything but read-only commands.
- Autonomy: how much runs without a person, in the letters of a mode:
rw(commands that only read) orrwx(what the tenant's matrix lets run alone, by default everything but identities and what is blocked), and a session may choose that every command waits. There is no level in between: the classes above reading each hold commands that run other programs or cut the connection. The lowest of several counts: the ceiling the installation gives the tenant, the letters the people involved hold on the server (the owner's letters of the server cap them), and the choice made for the session. The people involved are the owner of the session and whoever sent the message being worked on. A command of several parts runs alone only if every part does. - Approval, for what the level does not run: the tenant's matrix (Settings, Policy) says per class and level whether the command waits for one person, for two, or never runs; a server holds a cell stricter only. By default destructive commands and identities need two approvals from two different people, the second from an approver or administrator; everything else needs one.
- Execution over a pooled SSH connection with a timeout. Exit code, stdout and stderr are stored, shown to operators unredacted, and returned to the model scrubbed.
- Every step is written to the audit log with actor and timestamp.
A denial is final for that call: the model is told who denied it and why, and instructed not to rephrase.
A tenant's own block list
Beside the list built into the classifier, a tenant's administrators add commands that never run there (Settings, Commands): everywhere or on one server. A rule in words blocks those words in that order within one command of a line; a rule can also be a regular expression over the whole line. Instead of blocking, a rule can hold a command to a higher class ("counts as"), never a lower one: the matrix then decides as it does for that class. It is asked when a command is proposed and again right before it runs, so a rule added after an approval still holds. A rule names a spelling, and a command can be spelled another way: the list is a net for commands a team knows it never wants, and a host that must not change at all is set to read-only.
Server lock
A host's owner can lock it against changes without access to Heinzel Pro, in a file on the host that the open Heinzel honours too:
# /etc/heinzel.conf
allow_changes = false
reason = Database migration until 20 OctBefore a command that changes the host is proposed, and again right before
an approved one runs, Heinzel Pro reads the file over SSH. Only an absent
file or allow_changes = true lets the change through; an empty or
unreadable file, any other value, or a check that did not answer refuses it,
with the owner's reason on the command. Reading runs as before. The
classifier blocks every command that writes, moves or deletes the file, so
only the owner lifts the lock. It holds Heinzel back, not a person with
root on the host.
Emergency stop
A tenant's administrators have one switch (Settings, Policy) and the installation's administrators one for all tenants (Tenants). Engaging it needs a reason. It stops every session, takes back every command that waits for approval, cuts off commands that are running on hosts, and from then on no session starts, no command runs, no schedule fires and nothing resumes after a restart, until it is lifted. It is asked where work begins and again where a command is about to run. Who engaged and lifted it, and why, is in the audit log; a banner on every page says that it is on.
SSH
- Key pairs generated in the application, one per server or one shared by a
group of servers, private key encrypted with AES-256-GCM. No import, no
export. Nothing is written to
~/.ssh. - Ed25519 unless you pick ECDSA (P-256, P-384, P-521) or RSA 4096 for a host
or a policy that demands it, when creating or rotating a key. RSA keys sign
with SHA-2 only, so a host that knows nothing but SHA-1
ssh-rsasignatures (OpenSSH before 7.2) stays out of reach. - Host keys are pinned on first connection (or entered up front). A changed host key refuses the connection, marks the server, and alerts administrators.
- Only public-key authentication; no passwords, no agent forwarding.
- SSH certificate authorities are read, not operated. The connection test
records the user CA a host trusts, its principals and revocation settings and
the expiry of its host certificates. Heinzel Pro signs nothing, writes no
sshd directive and logs in with its own key, so a host that accepts
certificates only (
AuthorizedKeysFile none) cannot be managed.
What is left on a host
A command that only reads runs inside its SSH channel and leaves nothing. A
command that changes something, or that was given a longer timeout, runs as a
job: a directory ~/.heinzel/jobs/<command id> in the account it runs as,
mode 700, holding the command text, its output, its exit code and two
process ids. Heinzel Pro reads the result from there, also after its own
restart, and removes the directory once the result is stored. A directory
that nobody came back for is removed after seven days, at the next job's
start on that host.
A command that reads a secret or a file's new content on its input always stays in the channel, so neither is written to the host's disk for it. The command text of a job is on the host for as long as the job runs; a secret never appears in it, see Secrets.
Stopping a session, a timeout and the emergency stop end the job through the
process group sshd gave it: TERM first, KILL for what does not listen. The
layout is the one of the open Heinzel's rule ssh-connections, section 4.
VPN
- Each VPN connection is a WireGuard tunnel of its own in a process of its
own (
wireproxy, WireGuard in user space). The container gets no network interface and no privileges for it; nothing of this machine is routed into a tunnel. - Only what Heinzel Pro dials for a server that names the connection enters a tunnel. The helper opens a SOCKS5 door on the loopback address for it, with a password drawn anew at every start that only Heinzel Pro and the helper know.
- Two tenants with the same private network do not meet: the tunnels share neither addresses nor routes, and a server can only name a connection of its own tenant (composite foreign key).
- Inside the tunnel SSH stays what it is: its own key, the pinned host key. The tunnel replaces neither check.
- Keys are encrypted at rest like every secret. Where the other side dials
in, Heinzel Pro makes that side's key as well and keeps it, to show the
finished file again; it opens nothing but this one tunnel. The helper's
configuration file lies under
/tmponly while the helper starts and is deleted after. - A connection into a Tailscale network is a
tailscaledof its own in user space, so one device per connection. The auth key is encrypted at rest until the device has joined and is deleted then; what the device knows about itself (its state, with the device key) is kept encrypted with the connection after that. Nothing is sent to Tailscale's log servers. Unlike with WireGuard, this helper's SOCKS5 door has no password: it is on the loopback address of a container in which nothing else runs, and the separation of tenants hangs on the code that dials it alone. - The other side switches off without asking: stop the service, delete the device on the router, or remove it from the Tailscale network.
Tenants
- Every table that holds a tenant's data carries the tenant. The database layer binds each process (a request, a LiveView, the agent, a job, an SSH connection) to one tenant and adds that tenant to every read, update and delete it issues. A process bound to no tenant cannot read such a table at all: the query raises. An insert without tenant violates NOT NULL.
- References between tenant tables carry the tenant in the foreign key, so the database itself refuses a server that points at another tenant's key or a command on another tenant's server.
- The agent runs bound to the tenant of its session. Its tools look up servers by name within that tenant only; another tenant's host is an unknown server to it, and its model provider and API key are its tenant's.
- The tenant a sign-in works in is fixed with its first request and checked against the membership on every request. A sign-in whose tenant is forged, closed, or no longer open to the user is sent to choose again; the request itself is dropped. A page carries the tenant it was rendered in and does not reconnect into another.
- Each chain of the audit log is exported on its own: a tenant's file holds nothing of the installation's log (sign-ins, addresses of people who work elsewhere).
- Live updates and alerts are published per tenant. Background jobs carry their tenant and are cancelled when it is closed.
- Each tenant has an audit chain of its own, numbered from one, and reads, exports, verifies and prunes only that; the tenant and the number are under the hash. Logins and what else happens outside a tenant form the installation's chain, read by its administrators. No tenant can tell from its log how much another one does.
- What concerns an account as such (its passkeys, second factor, sessions, lock) is recorded in the installation's chain, since an account spans tenants. An alert about failed logins goes to the tenants the account works in.
- An account enters a tenant only after its owner accepted the invitation. Until then the inviting tenant sees the address it typed and nothing of the account, and cannot tell whether the address had an account before.
- A page that is open when a role changes, a member is removed or a tenant is closed is disconnected and comes back with the new rights. Closing a tenant also stops its agents and SSH connections.
- Addresses the server itself calls (model base URL, webhook) are set by the installation's administrators only, so a tenant cannot point the server into the network it runs in.
Addresses a tenant enters
A webhook, a model provider, a server: the installation dials what a tenant names. In your own data centre that is the point, and such an address may well lie in your network. An installation that is hosted for tenants its operator does not know refuses addresses that lead back to itself: the loopback address, every address of the machine's own interfaces, the private and link-local ranges, and networks the operator names. The form refuses such an address when it is spelled out. A name is judged when it is dialled, on the address it resolves to at that moment, and the connection goes to exactly that address, so a name that is pointed elsewhere after it was saved gains nothing. An answer that redirects is not followed. Servers behind a VPN connection of the tenant are reached through its tunnel and are not affected.
Two limits, both in VPN connections, whose helpers resolve names by themselves. The router's or login server's name is checked before each start, not pinned. And a server behind a connection is refused only when its address spells out this machine: a name is resolved inside the tunnel, where the installation cannot look, and what answers there is spoken to in SSH and nothing else.
Users
- Passkeys (WebAuthn) as a way to sign in. Only the public key is stored. A passkey must be discoverable and must verify its user, so it is two factors in one step and skips the TOTP code; an answer is accepted once, only for the configured public address, and a signature counter that goes backwards is refused as a copied key. Adding one needs a login within the last ten minutes. No attestation is requested, so any authenticator is accepted. With mandatory 2FA switched on, every user still sets up TOTP, because their password login stays possible.
- Every login is its own sign-in, bound to one browser. The account page lists them with system, address and last activity; a user ends any of them, or all but the current one, and the open pages of that device close at once.
- Password login with bcrypt, or magic links by mail, or OpenID Connect. Passwords can be turned off once SSO works.
- TOTP second factor per user; can be made mandatory.
- Roles (viewer, operator, approver, administrator) with single rights given or taken per person; a person can be shown some servers only. Rights to see take no seat.
- A sign-in that sits idle ends after a configurable time. Disabling a user ends their sign-ins immediately.
- Login attempts are rate limited per address; repeated failures raise an alert.
Audit log
Append-only at the database level (a trigger refuses UPDATE and DELETE), with
a SHA-256 hash chain over every row. "Verify chain" in the UI or
bin/heinzel_pro eval HeinzelPro.Release.verify_audit detects gaps and edits.
Retention pruning, when configured, is the only path that removes rows and is
itself recorded.
Secrets at rest
API keys, OIDC client secrets, webhook URLs, SSH private keys and TOTP secrets are encrypted with a key from the environment. Key rotation re-encrypts all of them; see upgrade.md.
Web
Content Security Policy without inline scripts, strict referrer policy, CSRF
protection on every form, HSTS when FORCE_SSL is on, WebSocket origin
checks, cookies signed and encrypted.
Known limits
- A link to a session is a secret in a URL: whoever gets it reads the session until it ends. The page blacks out what the redactor recognises as a secret, by pattern; host names, commands and anything it does not recognise are shown. Links are off unless a tenant's administrator allows them, and their requests stay out of the request log.
- At the level "full" nothing stands between the model and the server but the block list and the server's mode. Output of a command is text the model reads; on a compromised host that text can try to steer it. Give "full" only to servers and people where that is acceptable, and keep the ceiling of a tenant below it otherwise. The two-person rule does not apply to what runs without approval.
- Two things let a command run without a person below "full": a standing approval, which covers one exact command line and never a destructive one, and a raised level on one server for at most four hours. Both are an administrator's and both are in the audit log. Where the policy says so, what ran alone waits under Sessions, Countersign for a second person's signature.
- Output that reads like instructions to the agent is kept from the model until a person releases it. The check knows the usual phrasings; text written to avoid them passes, so it lowers the risk above and does not remove it.
- The classifier is a filter, not a sandbox. A
write-class command can still do damage; that is why it is shown to a person. - Secret scrubbing is pattern based. Output in unusual formats may pass through.
- Tenants share one database, one vault key and one application process. The separation is enforced in the application and by foreign keys, not by separate databases or row-level security in PostgreSQL. Whoever administers the installation, the database or the vault key reaches every tenant.
- The guard on queries looks at tables named through their schema. Raw SQL and fragments are outside it; the code has none on tenant tables.
- Licensing is verified offline with an embedded public key. Someone who rebuilds the container can bypass it; it is meant to keep honest customers honest, not to stop a determined attacker.