Security and secrets
Secret values must not be printed, logged, committed, placed in command arguments, or echoed into chat. The architecture invariants and protected-path rules define deny-by-default boundaries for secrets, auth code, migrations, compose/deploy infrastructure, agent rule files, lockfiles, and off-limits hosts.
The system wiki must not cite or summarize raw secret files. Safe evidence is metadata and source code that describes boundaries, not credential material.
Google Workspace secret boundary
Section titled “Google Workspace secret boundary”ADR 0100 moves Moth’s own Google identity onto the same Google API client path
as Mark while keeping separate blast-radius boundaries. Mark’s Google surface
uses MOTH_GOOGLE_SA_JSON plus MOTH_GOOGLE_SA_SUBJECT; Moth’s own
surface uses MOTH_IDENTITY_GOOGLE_SA_JSON plus
MOTH_IDENTITY_GOOGLE_SA_SUBJECT. credentials.py resolves those names only
through Agent Vault broker aliases, and the raw-secret startup denylist treats
both service-account JSON env names as secret carriers.
credentials.py also brokers per-vault memlink request signing through the
prontera oracle so the signing key never leaves prontera (INV-024):
memlink_scope_sign (HMAC, /sign, ADR 0143) and memlink_scope_sign_ed25519
(Ed25519, /sign-ed25519, ADR 0146; default-off behind
STEELMOTH_RUNTIME_MEMLINK_SIGN_ED25519_ENABLED). Both reuse the broker session
token + X-Agent-Vault-Client-Vault read-binding and fail closed (empty
signature) on any broker error.
Both compose files set STEELMOTH_SECRET_BROKER_BASE_URL to the prontera
URL so every steelmoth service resolves vault aliases at the same host as
the vault itself. The runtime gsuite package no longer imports the retired
SMTP/IMAP module for Moth mail; identity="moth" falls back to Mark with a
visible notice only when the Moth identity service-account JSON or subject is
not loaded. The broker aliases for Moth’s SMTP app-password/email remain
because other surfaces still use them; they are no longer the runtime Google
Workspace path.
Secret broker topology
Section titled “Secret broker topology”ADR 0105 (2026-05-23) consolidated the secret broker onto prontera. Vault material AND alias resolution now share one host:
steelmoth container → https://prontera.sole-augmented.ts.net/secrets/v1/secrets/{alias} → nginx (prontera) → systemd unit agent-vault-secret-broker (prontera loopback :14350) → Agent Vault upstream (prontera 100.100.245.3:14321)The deployed broker script lives at
prontera:/opt/agent-vault-secret-broker/agent-vault-secret-broker.py (NOT
in this repo). The alias→(vault, key) JSON map is loaded by systemd via
/etc/agent-vault-secret-broker/env. The nginx /secrets/ location block
proxies to loopback :14350 and is configured in
/etc/nginx/sites-enabled/agent-vault.prontera on prontera.
Steelmoth containers send their bearer token (from the
steelmoth_runtime_agent_vault_token Docker secret) as
Authorization: Bearer …; the prontera broker validates the token against
the upstream vault before resolving the alias. The Python client
(steelmoth_runtime.credentials) caches each alias for 60 seconds in-process
to keep the steady-state cost effectively free.
The private compose scripts avoid persistent runtime-token files on the
Steelmoth VPS. When a runtime-facing compose start lacks a non-empty
STEELMOTH_RUNTIME_AGENT_VAULT_TOKEN_FILE, scripts/compose-up.sh delegates
to scripts/with-ephemeral-runtime-token.sh. The wrapper copies a runtime
identity token from an existing mounted Docker secret, a systemd credential, or
explicit one-shot stdin into a private temporary directory, points Compose at
that file for the duration of the start/recreate, and deletes the temporary
source afterward. Stopped runtime containers with stale deleted temp-file
mounts are removed before startup so compose can recreate them with a fresh
ephemeral source. The wrapper does not write /etc/agent-vault/*.token files.
This concentration is required by INV-024 (architecture invariant) and
HARD RULE 10 (agent-instruction rule). No steelmoth compose file,
sidecar, library, or in-process service may proxy vault aliases — the
prontera URL is the only resolution path. The
prontera-assets/agent-vault-secret-broker/ directory in this repo holds
documentation-only deployment artefacts (Dockerfile-equivalent, README) for
the prontera-side install; nothing in it is loaded as runtime code in any
steelmoth container.
Adding a new alias means editing the systemd EnvironmentFile= on prontera
and reloading the service (sudo systemctl reload agent-vault-secret-broker).
It does NOT mean adding env vars to any compose file in this repo.
The memlink scope-auth HMAC (alias memlink-scope) is one such alias, notable
because two services resolve it through this one path: the mark-side signers
(steelmoth-api / steelmoth-worker-moth / stm-control-plane) via
credentials.secret_value("MEMLINK_SCOPE_HMAC_SECRET"), and the Memlink verifier
via memlink_hybrid.infrastructure.config._scope_auth_credential() (which reuses
the embedding key’s in-process BrokeredSecretSupplier — one broker client, not a
second). It is never an env var or .env value: an earlier cutover attempt that
materialized it as AGENT_VAULT_SESSION_TOKEN_MEMLINK_SCOPE in the repo-root
.env (#168/#169) was reverted as an INV-006/INV-024 breach, and
scripts/check-agent-vault-no-local-secret-materialization.py now fails CI on any
re-introduction in a compose / .env / script file. See ADR 0135 →
“Signing-secret resolution (broker-only)”.
The public dashboard auth path follows the same boundary.
scripts/compose-dashboard-deploy.sh resolves startup authority through
scripts/with-ephemeral-runtime-token.sh, writes the one-shot token into a
short-lived Docker volume mounted at /run/steelmoth-dashboard-token, and
starts stm-dashboard-app with AGENT_VAULT_TOKEN_FILE pointing at that
volume. The dashboard entrypoint deletes that token file immediately after
reading it. It first attempts to mint an Agent Vault session, but Agent Vault
0.22 requires member-level authority for session minting while the runtime
identity is intentionally proxy-only. When session minting is denied, the
entrypoint uses the same proxy token only in memory for the read-only broker
fetch, then unsets it before Node starts. The entrypoint fetches
dashboard-auth-secret and dashboard-authentik-client-secret through the
prontera broker and writes those dashboard secrets only to container-local
temporary files under the runtime secret directory. A Node preload reads those
files after process start, sets the runtime-only Auth.js env values, deletes
the _FILE env names, and removes temporary files under the runtime secret
directory. The alias names are safe to document; the values are not.
Broker write boundary
Section titled “Broker write boundary”The broker-write surface is source-grounded in
docs/specs/prontera-broker-write-endpoints.md v4. The accepted target makes
Mark the first client (client-mark), reserves client-{slug} for future
tenants, keeps runtime identities read-only (proxy), and requires the
prontera broker to verify a fresh Authentik step-up MFA proof before every
dashboard/user secret write.
Dashboard self-service writes are limited to client-owned aliases such as
client-mark-*; system/operator aliases such as moth-* stay outside the
dashboard write path. No standing write-capable token may live on the
steelmoth VPS.
As of the 2026-05-30 implementation slice, the live prontera broker has
guarded challenge/write/delete handlers, a non-secret client registry for
mark -> client-mark, the corrected Agent Vault mutation API shapes, and a
dedicated prontera-local writer token
(mark-steelmoth-secret-broker-writer-v2) scoped only to client-mark.
When /etc/agent-vault-secret-broker/env is loaded, --check-config reports
writes_configured=true.
Broker writes are not authorized by dashboard trust alone. Challenge creation
uses the dashboard Authentik OAuth access token for Mark/client binding, and
PUT/DELETE require a fresh Authentik step-up token with matching nonce, fresh
auth_time, accepted MFA amr/acr, and matching subject hash. Missing
step-up still returns 401, moth-* challenge creation returns 403, and the
runtime token direct write probe returns 403.
The dashboard Telegram connector implements the first self-service write path.
It stores the user-entered bot token only in server memory during the
five-minute challenge window, redirects through Authentik with prompt=login,
max_age=0, the broker nonce, S256 PKCE, and the provider-advertised
acr_values when present, then writes
client-mark-telegram-bot-token to client-mark as
CLIENT_MARK_TELEGRAM_BOT_TOKEN through the broker. The token value is not
sent to chat, the runtime API, memlink, logs, or the steelmoth VPS filesystem.
The write-challenge authorization resolves the Authentik OAuth access_token
from a short-lived server-side cache keyed by an Auth.js access_token_ref;
older sessions without a valid cached reference are stopped with
telegram_session_refresh_required before prontera is contacted.
The prontera Telegram Bot API broker now resolves bot credentials from Agent
Vault by preferring client-mark/CLIENT_MARK_TELEGRAM_BOT_TOKEN and falling
back to the existing client-mark/TELEGRAM_TOKEN. That fallback keeps the
current bot path working until Mark completes the dashboard connect flow.
The urgent independent prerequisite was to ensure the runtime Agent Vault
grants are proxy where the runtime should be read-only before any broker
write path is exposed; the 2026-05-30 handover records that
mark-steelmoth-runtime-vps has client-mark:proxy and staging:proxy, and
that a direct runtime write probe returns 403 without creating a probe
credential row.
Admin authorization (RFC 0074 transition)
Section titled “Admin authorization (RFC 0074 transition)”Steelmoth dashboard authentication is fail-closed in two stages before route handlers run:
- Transport/header trust — the runtime derives peer metadata from the
immediate socket peer (
request.client.host) rather than from forwarded headers. When internal hop auth is configured, the dashboard-to-runtime hop credential is required before the runtime accepts any trustedx-authentik-*identity headers.x-forwarded-foris metadata only; it can help logs and forensics after the runtime boundary passes, but it is not a credential and must not grant peer trust. Focused regression tests model the socket peer throughTestClient(..., client=(host, port))and assert that spoofingx-forwarded-for: 127.0.0.1from an untrusted socket returns401 UNAUTHENTICATED, including on client-provisioning routes. - Principal identity —
RuntimeConfig.primary_authentik_emailrejects any Authentik-issued user whose email does not match the configured canonical operator with403 PERMISSION_DENIED— applied byrequire_authed(web_chat/auth.py), which layers the pin over the shared_authenticatehelper (observer fix S1.2; there is NO observer exemption — an observer principal never reaches a plainrequire_authedroute). This is a single-operator pin; it does not grant role. Removing this gate is explicitly out of scope of RFC 0074 (multi-tenant ADR 0072 territory). - Role —
_role_for()(web_chat/auth.py) returns one of THREE roles (ADR 0225):"admin"iff the caller is a member ofconfig.admin_group(defaultdashboard-admins); else"observer"iff the caller is inconfig.observer_group(defaultdashboard-observers) AND this runtime is the operator brain (is_canonical_authentik_email(config.primary_authentik_email)— observer fix S1.1: a client gateway ignores the observer group); else"user". Admin is checked FIRST, so a principal in both groups is admin — never an escalation. Group membership flows through the OIDCgroupsclaim, parsed at_split_groups()from the comma-separatedx-authentik-groupsheader that the trusted Next.js proxy injects. The observer is the read-only operator-view role: it is admitted ONLY viarequire_admin_read(GET/HEAD admin reads) and the two declared read-shaped POSTs; every write and every un-redacted secrets read staysrequire_admin. Group changes take effect at the user’s next sign-in — revoking the observer group lags for the current session’s JWT lifetime (documented limit, operator-approved 2026-07-25).
The legacy email-based admin rail (config.admin_emails,
STEELMOTH_ADMIN_EMAILS, MOTH_HYBRID_ADMIN_EMAILS) has been fully
removed as of RFC 0074 PR-D: the RuntimeConfig field is gone, the env
loading is gone, the compose envs are gone, owner_identity._first_admin_email
is gone, and the wire contract no longer carries admin_emails or the
legacy requesting_user_is_self_in_list alias. Settings PATCHes to
STEELMOTH_ADMIN_EMAILS or MOTH_HYBRID_ADMIN_EMAILS still return
400 INVALID_ARGUMENT with guidance pointing at the configured Authentik
admin group, as a permanent operator-visible signal that those env keys
are no longer meaningful.
The dashboard frontend matches: dashboard/lib/auth.ts:isAdmin() trusts
only user.role === "admin"; dashboard/middleware.ts:isDocsAdmin()
checks dashboard-admins for the docs host edge gate;
dashboard/lib/private-auth.ts defaults the private loopback bypass
identity groups to ["dashboard-admins"]; and
dashboard/components/settings/admin-sections/AdminAssignmentsSection.tsx
is a read-only viewer + “Manage in Authentik →” link.
Env aliases relevant to admin role + identity:
| Env (current alias) | Legacy alias | Status |
|---|---|---|
STEELMOTH_ADMIN_GROUP |
MOTH_HYBRID_ADMIN_GROUP |
Active — sole backend role source (default dashboard-admins) |
STEELMOTH_PRIMARY_AUTHENTIK_EMAIL |
MOTH_HYBRID_PRIMARY_AUTHENTIK_EMAIL |
Active — principal identity pin (single-operator gate); not a role source |
STEELMOTH_ADMIN_EMAILS |
MOTH_HYBRID_ADMIN_EMAILS |
Removed by RFC 0074 PR-D; PATCH on these keys returns 400 INVALID_ARGUMENT with deprecation guidance |
Break-glass mechanism
Section titled “Break-glass mechanism”ADR 0098 documents the single break-glass path for Authentik unreachability:
SSH into the VPS, set STEELMOTH_ADMIN_GROUP to the permanent
emergency-admins Authentik group (which the operator maintains as a
never-removed-from membership), restart the API container, log in. There
is no in-band bypass code path. The full procedure lives in
docs/operations/runbook.md. The existing dashboard/lib/private-auth.ts loopback bypass is scoped to
the private dashboard container (compose.steelmoth.private.yml)
as a dev/internal-tooling path and is NOT positioned as the public-dashboard
break-glass.
Maintenance Notes
Section titled “Maintenance Notes”- Update this page when protected paths, secret checks, Agent Vault boundaries, host-boundary rules, or the admin-authorization contract change.
- Do not add secret-bearing paths to system-wiki sources.
Known Unknowns
Section titled “Known Unknowns”- This page does not replace a threat model for a new runtime or dashboard surface.
- Multi-tenant admin role semantics under ADR 0072 are not yet specified; a
future ADR will revisit whether
config.admin_groupbecomes per-tenant.

