Runtime hardening¶
This document describes the hardening defences Merovingian applies at build time, at startup, and while serving traffic. It covers defences that are cross_platform_ and defences that are platform_specific (Linux, FreeBSD, NetBSD, OpenBSD, and the portable/service-manager profile).
For open hardening work and production-gating status, see
todos/capability-gaps.md.
Cross-platform defences¶
These defences are present on every supported platform, either inside the binary or in the service-manager configuration that ships with the packages.
Build-time toolchain hardening¶
meson.build adds the following compile-and-link hardening whenever
-Dhardening=true is set (the default for packages):
| Defence | Where it lives | Notes |
|---|---|---|
| Stack protector | hardening_compile_flags (-fstack-protector-strong) |
Compiler inserts stack canaries. |
| Stack clash protection | hardening_compile_flags (-fstack-clash-protection) |
Guards against stack clash attacks. |
| Control-flow protection | hardening_compile_flags (-fcf-protection=full) |
CET/IBT on x86_64. |
| FORTIFY_SOURCE | hardening_compile_flags (-D_FORTIFY_SOURCE=3) when optimization != '0' |
Checked libc wrappers. |
| Hidden visibility | hardening_compile_flags (-fvisibility=hidden) |
Limits ELF symbol exposure. |
| Trivial auto-var init | hardening_compile_flags (-ftrivial-auto-var-init=zero) |
Uninitialised locals are zeroed. |
| Position-independent executable | hardening_compile_flags (-fPIE) and link args (-pie) |
Enables ASLR. |
| No-exec stack | hardening_link_flags (-Wl,-z,noexecstack) |
ELF GNU_STACK note is non-executable. |
| RELRO + BIND_NOW | hardening_link_flags (-Wl,-z,relro -Wl,-z,now) on GNU/Linux |
Full RELRO; dynamic relocations resolved at load time. |
| Static PIE fallback | scripts/build-static-linux.sh (-static-pie) |
Fully static, position-independent musl build for Linux. |
The startup hardening self-check probes the same flags at runtime:
compiler hardeningchecks for__SSP__/__SSP_STRONG__/__SSP_ALL__,_FORTIFY_SOURCE > 0, and__PIE__/__pie__.linker hardening,PIE, andRELROparse/proc/self/exeon Linux to confirmPT_GNU_RELRO,DT_BIND_NOW, andPT_GNU_STACKwithoutPF_X. Static or non-ELF builds reportunknownrather thandisabled.
C++ memory and type safety¶
The project uses C++26 with strict rules that reduce memory-safety bugs:
- RAII everywhere; no raw
new/delete,malloc/free. - Smart pointers for dynamic ownership; references preferred over pointers.
core::FileDescriptoris a move-only RAII wrapper that closes its fd on destruction and providesset_cloexec().core::SecretBufferowns isolatedsodium_mallocpages, explicitly checkssodium_mlocksuccess, and wipes/unlocks throughsodium_freeon destruction. Moves transfer ownership and leave the source empty. Separate allocations do not share data pages, so destroying one cannot unlock another live secret. It is move-only and non-copyable. Derived 32-byte keys useSecureKeyBytes; every copy must obtain its own locked allocation or fail. Linux kernel mapping checks and forced locked-memory exhaustion cover the storage and both caches. No key-derivation or new-token-issuance path silently accepts an unlocked key.src/corelinks libsodium; historical derivations are retained (ADR-0128).
Cryptographic boundary¶
All cryptography is delegated to libsodium. The project does not implement its own primitives.
sodium_init()is wrapped in per-modulestaticsodium_is_ready()helpers so it is called once and failures are checked (src/events/event_signer.cpp,src/homeserver/auth_service.cpp,src/crypto/secret_box.cpp, etc.).- Passwords and the registration token are hashed with Argon2id
(
crypto_pwhash_str/crypto_pwhash_str_verify). - Access-token HMAC and signing-secret encryption keys are derived from the
operator's master key with domain-separated libsodium generic hashes
(
crypto_generichash). - The Ed25519 server signing secret is stored encrypted at rest via
crypto::secret_box_encrypt(XSalsa20-Poly1305 with a random nonce). - Constant-time comparison for fixed-size values uses
sodium_memcmp. Variable length secrets are compared by hashing both inputs with a domain-separatedcrypto_generichashcontext and then comparing the fixed-size digests withsodium_memcmp, so the comparison does not leak the secret length. - Short-lived plaintext secret buffers attempt
sodium_mlockand are wiped on release. Master-key loading andSecureKeyBytesrequire a successful lock; generalSecretBufferallocations record failure without rejecting it. Not every signing-secret copy checks that result. See crypto-boundary.md for the precise guarantees. - The seccomp-bpf allowlist permits
mlock,munlock,mlockall,munlockall, andgetrandomso libsodium can lock pages and fetch entropy. - Main alone admits
fchmod(fd, 0600)for descriptor-bound SQLite permission tightening; any other mode is denied. Federation and thumbnail profiles still deny allfchmod, and path-based chmod remains denied. The grant is mode-specific, not file-specific; database admission separately validates descriptor ownership/type. See ADR-0129. - Main also admits only
umask(0077): supervised executables inherit main's seccomp across exec and must establish their private startup mask before their own sandbox installation. Other masks remain denied in main, and both workers deny every umask change once their stricter profiles are installed. Direct launches still run the same private-mask startup call. See ADR-0130.
Configuration and secret file permissions¶
Before loading configuration or secret files, the bootstrap path validates POSIX metadata:
src/platform/file_metadata.cppreads file kind and mode withlstat.- Configuration and TLS certificate files must be regular files without group
or other write or any execute bit (
is_secure_config_file). - Secret files (master key, TLS private key, registration token) must be
regular owner-read-only, non-executable files with no group/other access
(
is_secure_secret_file). Until 0.12.5 the predicate did not actually checkowner_write, so0600was accepted despite this line; it now enforces what it documents, and operators upgrading from an earlier release need a one-timechmod 0400on each secret file (seedocs/user-manual.md). security.secrets.master_key_filewas added to the startup metadata check in 0.12.13. Before then the file was opened with a plainstd::ifstreamwithout an ownership, symlink, or file-kind check, so a group/world-readable or swapped-in master key was silently accepted.-
The
--log-filepath (ISO-6, ADR-0146) is openedO_NOFOLLOW|O_CLOEXECand used only when it is a regular file the process owns; it is then made 0600 and truncated. A symlink, FIFO, device or another user's file is refused and the logger writes no file output. -
Core-dump policy.
setrlimit(RLIMIT_CORE, 0)and, on Linux,prctl(PR_SET_DUMPABLE, 0)must both succeed. The prctl result was discarded until 0.12.5:RLIMIT_CORE=0stops the kernel writing a core file for an ordinary crash, but acore_patternpiping to a handler is not bound by the process rlimit, so the process image could still be captured while the policy reported success.setrlimitis POSIX and was only ever applied on the Linux path; BSD servers now clamp it too.
Platform coverage of the hardening controls¶
The startup self-check runs the same probe on every platform where the control
exists, and reports "not applicable on this platform" only where the mechanism
itself does not exist. Before 0.12.5 the POSIX probes were behind an __linux__
guard, so a BSD server reported unknown for controls it had applied and
is_ready() refused to start it — on platforms that are documented Tier 1.
| Control | Linux | FreeBSD | OpenBSD | NetBSD |
|---|---|---|---|---|
Privilege drop / filesystem restrictions (geteuid) |
probed | probed | probed | probed |
Core-dump policy (RLIMIT_CORE) |
probed, plus PR_GET_DUMPABLE |
probed | probed | probed |
no_new_privs |
probed | n/a | n/a | n/a |
| Capability bounding | probed | n/a | n/a | n/a |
| seccomp-bpf | probed | n/a | n/a | n/a |
| pledge / unveil | n/a | n/a | probed | n/a |
| Capsicum | n/a | probed | n/a | n/a |
Thumbnail worker sandbox¶
The out-of-process thumbnail worker decodes untrusted PNG and JPEG bytes, so it enters a sandbox before reading any input. By that point stdin and stdout are already open and it needs nothing else for the rest of its life.
| Platform | Primitive |
|---|---|
| Linux | seccomp-bpf syscall allowlist (platform::apply_decoder_seccomp_filter()) |
| OpenBSD | pledge("stdio") |
| FreeBSD | Capsicum capability mode (cap_enter) |
| NetBSD | rlimits only — no equivalent in-process primitive |
NetBSD is the remaining gap. Until one exists, run the worker under whatever confinement the service manager provides; the rlimits (address space, CPU, file size, descriptor count) still apply.
The library report the server requests at startup (0.12.45) and --version are
answered inside the same sandbox: the worker applies its hardening first and only
then calls libpng or describes either library, and it writes the answer with
write(2) as it does a thumbnail, never through iostreams (whose first write makes
the C library fstat stdout).
The Linux profile is its own allowlist, not the general server filter or
the federation worker's filter (fixed 0.12.7). Before this fix, the
decoder's harden() called platform::apply_seccomp_filter() — the general
server allowlist, which permits socket, connect, sendto, openat,
unlinkat, execve, and clone — so on the primary production platform the
decoder was effectively unconfined, while its own source comment claimed a
decoder exploit had "nothing to reach for". apply_worker_seccomp_filter(),
added for the federation worker, is not a substitute either: it removes
only execve/execveat and still permits sockets, openat, and clone,
because the federation worker genuinely needs network and thread creation.
apply_decoder_seccomp_filter() is a third, dedicated profile whose
allowlist is the seccomp-bpf equivalent of pledge("stdio") — I/O on
already-open descriptors, memory management, the per-thread syscalls glibc
issues from inside malloc, signal return, clock reads, and exit — denying
every socket call, every path-based filesystem call, execve/execveat,
and clone/clone3/fork/vfork. All three platforms now confine the
decoder equivalently. tests/unit/test_seccomp_hardening.cpp asserts the
decoder profile is strictly tighter than the worker profile on sockets,
openat, and clone, so a later "simplification" onto the shared filter
fails the suite rather than silently reopening this gap. The rule this
sets for future code: sandbox profiles are named for the threat they
contain, not for the kind of process that installs them — a new isolated
child process gets its own profile unless its syscall needs are genuinely
identical to an existing one. See
ADR-0053.
* These checks run in src/main.cpp before any listener is created.
Signal handling and graceful shutdown¶
src/net/shutdown_signal.cpp installs a self-pipe and SIGINT/SIGTERM handlers.
The handler does only signal-safe work: it writes one byte to the pipe and sets
an atomic flag. The main thread unblocks poll() and initiates clean shutdown.
SIGPIPE is ignored process-wide so a peer that vanishes mid-write cannot
terminate the process (HTTP-5, security-audit-report-2026-09-29.md). Before the HTTP-5 fix
this was only true after the first thumbnail was generated, because the sole
SIG_IGN sat in src/media/thumbnailer.cpp. OpenSSL's socket BIO writes with
plain write() and no MSG_NOSIGNAL, so a TLS client that completed the
handshake, sent a partial request head and reset the connection made the
server's 408 write raise SIGPIPE, and the default disposition killed the
homeserver. The plain-HTTP and IPC paths use MSG_NOSIGNAL and were never
affected. Every executable (merovingian-server, merovingian-fed-worker,
merovingian-thumbnail-worker, merovingian-db-migrate) now calls
platform::ignore_sigpipe() (sigaction(SIGPIPE, SIG_IGN),
src/platform/signal_hardening.cpp) first thing in main(), before any thread
starts and before any seccomp filter exists. A failure is fatal: the server and
db-migrate exit with bootstrap::ExitCode::runtime_start_error (80), the two
workers exit 1. The lazy ::signal(SIGPIPE, SIG_IGN) in the thumbnailer is
kept as a second line of defence for the unit and integration tests, which call
the thumbnailer without going through any main() of ours.
tests/unit/test_signal_hardening.cpp forks a child that resets SIGPIPE to
SIG_DFL (so it never inherits a harness SIG_IGN), and proves both that the
child is killed without the call and that it survives with it.
Out-of-process thumbnail worker sandbox¶
The main server never decodes untrusted image bytes in-process. It spawns
merovingian-thumbnail-worker via fork()/execve() (src/media/thumbnailer.cpp
and src/media/thumbnail_worker_main.cpp):
- Parent and child communicate through
O_CLOEXECpipes (pipe2()on Linux,pipe()+fcntl(F_SETFD, FD_CLOEXEC)elsewhere). - The child
dup2()s the pipe ends onto stdio, then callscore::close_all_file_descriptors_except()with astd::span<int const>to close every inherited fd other than stdio. The span overload is allocation-free and async-signal-safe so it can run immediately afterfork()in a multi-threaded parent. The sweep: - uses
/proc/self/fdon Linux; - skips
/dev/fddirectory walks on FreeBSD, NetBSD, and OpenBSD (those directories contain an entry for every possible fd, not only open ones); - falls back to a capped
fcntl(F_GETFD)scan of at most 1024 descriptors. - The child executes with only
PATH=/usr/bin:/bin, including the FreeBSD descriptor-basedfexecve()path. Parent environment entries are not inherited. Before exec, fd 2 is redirected to an immutable, RAII-owned/dev/nullsink opened before filesystem sandboxing. Its original descriptor is CLOEXEC and kept above stdio even when the service starts with stderr closed. A missing sink or failed stderr duplication prevents execution with inherited diagnostics. - Before
execve()the child setsprctl(PR_SET_NO_NEW_PRIVS, 1, ...)on Linux so a compromised worker cannot escalate through setuid/setcap helpers. - Inside the worker,
main()callsmedia::apply_decoder_hardening()(include/merovingian/media/decoder_hardening.hpp,src/media/decoder_hardening.cpp) before reading a single byte of stdin, and it is fail-closed (0.12.13 audit fix): the sequence clamps resources, then installs the platform sandbox, and if any applicable control fails the worker prints the failed control's name to stderr and exits1without ever reading its input or invoking libpng/libjpeg-turbo. Before 0.12.13,harden()discarded every hardening call's result withstd::ignore, so a sandbox that failed to install still let the worker decode attacker-controlled bytes completely unconfined, silently. The parent (src/media/thumbnailer.cpp) already treats a worker that exits before producing output as a normal failed-thumbnail response (malformed/ empty worker response → HTTP 502), so this fails closed without any parent-side change. RLIMIT_CPU= 15 s in production release builds, 60 s in non-release builds, 120 s under sanitizers (ASan/UBSan/TSan are slow on CI QEMU);RLIMIT_FSIZE= 64 MiB;RLIMIT_CORE= 0;RLIMIT_NOFILE= 16;RLIMIT_AS= 768 MiB in production builds (skipped under sanitizers); OpenBSD has noRLIMIT_AS, so there the same cap isRLIMIT_DATA, which on OpenBSD also bounds anonymousmmap(how itsmallocallocates);platform::apply_decoder_seccomp_filter()is installed in production builds (skipped under sanitizers) — the decoder's own allowlist, not the general server filter and notapply_worker_seccomp_filter(); see "Thumbnail worker sandbox" above and ADR-0053.- Each control's syscall is behind an injectable function table
(
DecoderHardeningOps) sotests/unit/test_media_decoder_hardening.cppcan assert the fail-closed sequencing and the failed-control name for every control without actually installing a broken sandbox in the test process. - The parent independently validates the complete PNG signature, the first chunk's 13-byte IHDR framing and nonzero, legal dimensions, and their equality to the worker's reported dimensions. A malformed or mismatched successful response produces 502 without media bytes, content type or dimensions. This is fixed-header validation, not parent-side decoding or CRC/image-data validation.
- The worker rejects decoded images when either axis exceeds 4096 or their area
exceeds the effective pixel budget. Zero requests still use its
4,096,000-pixel ceiling; positive requests cannot raise that ceiling. The final
thumbnail and actual crop-fill intermediate share that pixel budget (MED-8).
Before resampling, the worker checks the rounded fill axes in
uint64_tand uses division to reject excess area before narrowing or allocating. This does not add a decoded-axis limit to intermediate images or an aggregate-memory cap. After hardening, a prebuilt emptyinternal_errorframe lets the main exception boundary contain unexpected C++ failures without another string allocation. Startup exceptions before that frame exists fail closed without reading input. Signals and closed-pipe delivery are not guaranteed by the exception boundary. See proposed ADR-0143.
Out-of-process federation worker IPC security¶
When federation is enabled, merovingian-server spawns merovingian-fed-worker
and communicates through an AF_UNIX SOCK_STREAM socket pair created with
SOCK_CLOEXEC before the child is spawned via posix_spawn. The worker
inherits the client fd at file descriptor 3 (--ipc-fd), the read end of a
one-shot pipe carrying the IPC auth key at file descriptor 4 (--ipc-key-fd,
see below), and — when database.backend=postgresql and a separate worker
database role applies (ADR-0062 part 2, see below) — the read end of a third
one-shot pipe carrying that role's connection URI at file descriptor 5
(--db-uri-fd). All three are placed by posix_spawn_file_actions_adddup2,
which clears FD_CLOEXEC only on the child's copies. Every other fd is
FD_CLOEXEC and does not survive the exec. None of the three sources is
allowed to sit on fd 3, 4 or 5 in main: when one does (for example the socket
pair returns fds 0 and 3 because main runs with stdin closed),
make_worker_ipc_socketpair and make_worker_secret_pipe first move it past
fd 5 with F_DUPFD_CLOEXEC. A source already on its own target would make
the adddup2 a same-fd dup2, which some libcs treat as a no-op that leaves
FD_CLOEXEC set, and the worker would start without that fd; a source on
another target would be overwritten before its own dup2 ran.
The channel is hardened against a local attacker who gains access to a separate process on the same host:
- Authenticated ephemeral encryption (#318, key handoff redesigned
0.12.13 finding N1 / ADR-0062):
every session uses a fresh
crypto_kx_keypairpair andcrypto_secretstream_xchacha20poly1305AEAD, and the key exchange itself is authenticated — both processes MAC each other's ephemeral public keys (and role) withcrypto_auth, keyed by the same 32-byte IPC auth key, before deriving session keys. Through 0.12.12 both processes independently derived that key from the operator master-key file (domain-separated labelmerovingian:ipc-channel-auth:1), which meant the worker opened the master key file itself. As of 0.12.13 only main ever opens that file:WorkerPoolderives the auth key once (crypto::derive_ipc_auth_key) andWorkerSupervisor::spawn_and_connectwrites exactly those 32 bytes into apipe2(O_CLOEXEC)pipe and closes its own write end (make_worker_key_pipe). The read end staysFD_CLOEXECin main — clearing it in the multithreaded parent would let a concurrent spawn inherit the key — and anadddup2file action places it at fd 4 in the child only (--ipc-key-fd 4). The worker (federation_worker::read_ipc_auth_key) reads exactly that many bytes, requires EOF immediately after (rejecting a short or long write as fail-closed rather than silently truncating or padding), and closes the fd. A local process that reaches the inherited ipc-fd without a valid key on the key-fd cannot complete the handshake or inject AEAD frames. The session keys are never stored or logged; a captured IPC stream is useless after the session ends. - The worker's own config copy cannot reopen the master key file either:
immediately after the key-fd read succeeds,
WorkerEventLoop::run()callsfederation_worker::clear_master_key_file(), which emptiessecurity.secrets.master_key_fileon the worker's in-memoryconfig::Configbeforehomeserver::start_runtime()is ever called — so no runtime code path reachable inside the worker, today or added later, has a path string to pass tocrypto::load_master_key_material. See docs/threat-model.md, "Operator master key reachable from the federation worker" — part 3 (Linux Landlock, below) closes the filesystem-level residual gap this entry used to record. - A separate, least-privilege database login, delivered the same way
(ADR-0062 part 2): when
database.backend=postgresql,WorkerPool::WorkerPoolreadsfederation.worker.database_uri_fileonce (same owner-only-file validation asdatabase.uri_file) and hands eachWorkerSupervisorits owncore::SecretBuffercopy of the bytes.spawn_and_connectcreates a thirdpipe2(O_CLOEXEC)pipe (homeserver::make_worker_db_uri_pipe, a generalization ofmake_worker_key_pipethat keeps the read end off all three fixed child fd numbers), writes the URI, and adds a thirdadddup2onto fd 5 (homeserver::kWorkerDbUriFd,--db-uri-fd 5) — again only when a separate URI actually applies; a SQLite backend or theallow_shared_database_credentials=trueopt-out means neither the flag nor the pipe exist at all. The worker (federation_worker:: read_worker_database_uri) reads until EOF (bounded to 4096 bytes, unlike the fixed-length auth key), rejects an empty or oversized result, andfederation_worker::apply_worker_database_urisets the delivered URI as the worker's own connection override while clearingdatabase.uri_file,runtime_role, andmigration_roleon its config copy — so the worker never has a path to main's credentials file and never attempts aSET ROLEonto a role it was never granted membership of. - Linux Landlock filesystem restriction (ADR-0062 part 3):
federation_worker::main()callsplatform::apply_worker_landlock()immediately before the worker seccomp filter below (Landlock's three syscalls are not on that filter's allowlist — applying Landlock first avoids adding a startup-only capability to it permanently) and before the event loop opens the database or handles any inbound request. It queries the kernel's Landlock ABI version, builds a rights mask downgraded to whatever that ABI supports, and grants exactly the pathsplatform::build_worker_landlock_rules()derives from the worker's own config copy: the SQLite database directory (read-write, required, whendatabase.backend=sqlite) plus a fixed set of best-effort, read-only or read-execute OS-integration paths (CA trust store candidates, resolver configuration, NSS/dynamic-linker library directories, timezone data). The master key file, both database URI files, and TLS private keys are never in the rule set, and the worker refuses to start if any rule covers one of them — a rule on a directory grants everything beneath it, so a secret kept beside the SQLite database would otherwise be exposed. The CA grants cover only the certificate stores, never all of/etc/sslor/etc/pki, which hold the conventional private-key directories. Because a seccomp filter survivesexecve, the worker inherits the main server's filter, which therefore allows the three Landlock syscalls. When the running kernel has no Landlock support (older than Linux 5.13, or disabled at boot), the worker refuses to start unlessfederation.worker.allow_without_landlock=trueis set, which logs CRITICAL on every start — mirroring the fail-closed seccomp policy below; any other Landlock failure (ruleset creation, a required rule, orlandlock_restrict_selfitself) is always fatal regardless of that opt-out. See ADR-0062, part 3, for the allowlist derivation. - No peer credentials in transit (#323): the main process verifies the
inbound X-Matrix signature itself and forwards only the verified peer identity
(
origin/key_id/sig_verified); the raw peeraccess_tokenandAuthorization/X-Matrixheaders are stripped from thefed_requestframe and never cross IPC, so a compromised worker cannot harvest or replay peer homeserver credentials. The Ed25519 signing key is never forwarded either, and the worker cannot ask main to sign: there is nosign_requestframe and the worker's provider refuses every request (#317, ADR-0078), so a compromised worker has neither the key nor a signing oracle. (The outboundAuthorizationheader that does cross IPC is our own request-bound X-Matrix signature, not a reusable peer credential.) - No filesystem socket path: the transport is an
AF_UNIXsocket pair with no pathname in the filesystem namespace, so there is no socket file to impersonate or intercept via filesystem access. - SOCK_CLOEXEC: the socket pair is created
SOCK_CLOEXECso it is not inherited by any further child processes. - Minimal worker environment (#330): the worker is spawned with an
explicit minimal environment (
PATHonly) rather than inheriting the parent environment, so no parent secret leaked via environment reaches the lower-privilege worker child. - Bounded IPC frames (#325): the per-frame cap is 24 MiB
(
merovingian::ipc::kIpcMaxFrameBytes) by default, lowered from a prior 50 MiB cap to bound memory-exhaustion DoS across concurrent workers; oversize frames are logged and propagated as send/request failures rather than silently dropped. Response bodies (fed_response/outbound_http_response) are base64-encoded before framing — a fixed 4/3 expansion — instead of raw JSON-string escaping, whose expansion depends on the response content and could otherwise push a response already at the response cap over the frame cap after escaping (issue #342).WorkerPooland the worker process each independently derive the channel's actualmax_frame_bytesfromsecurity.federation.join_response_max_sizeviaipc::frame_bytes_for_response_cap(main process:WorkerPool::WorkerPool; worker:WorkerEventLoop::run) — both sides parse the same--configfile, so they agree without negotiating the value over the channel itself. The 24 MiB constant is only the floor used when that config value is at its 16 MiB-equivalent minimum; raisingjoin_response_max_sizeraises the frame cap in lockstep, and both processes must restart to pick up a change (seedocs/user-manual.md). - Per-channel in-flight IPC cap (0.12.13 finding H2 /
ADR-0065): each
worker channel is independently capped by
federation.worker.ipc_max_in_flight_requests(default256). Main counts the requests it has accepted from that channel but not yet finished responding to; a request that arrives at the cap is rejected immediately with an explicitmain_overloadedreply. The worker turns that reply into a retryable503 M_UNKNOWNfor the remote server, preserving Matrix retry semantics, instead of silently dropping the request or queueing it on an unbounded handler pool. The cap is per channel, not global, so one slow or abusive worker cannot starve others and a crashed channel's count is released with the channel. - Bounded IPC dispatch queue (CRY-2,
ADR-0111): the in-flight cap is
applied when a request is dequeued, so the reader thread's queue in front of
it is bounded separately, by
federation.worker.ipc_max_dispatch_queue_count(default1024frames) andfederation.worker.ipc_max_dispatch_queue_bytes(default 128 MiB). A worker that floods past either marks its channel unhealthy and the reader stops, so the supervisor replaces the worker instead of main buffering without limit. Every IPC socket has a 30 s send timeout, so a peer that stops reading cannot pin a writer. - The worker cannot signal or re-limit other processes (ISO-2,
ADR-0112): the
worker seccomp filter has no
kill,tkill,setrlimitorprlimit64, and allowstgkillonly when its thread-group argument is the worker itself. On Landlock ABI 6 and later the ruleset also scopes signals to the worker's domain. The worker still shares main's uid, so these filters are what stopkill(getppid(), SIGSTOP).[iso2]intests/unit/test_worker_hardening_threads.cppruns the real filter in a forked child. - The worker opens only the socket families it uses (ISO-8,
ADR-0147):
socket()admitsAF_UNIX,AF_INETandAF_INET6;AF_NETLINKreturnsEAFNOSUPPORTso glibc'sgetaddrinfoaddress-sorting probe falls back instead of killing the worker; any other family (AF_ALG,AF_PACKET, …) kills it.socketpair()admitsAF_UNIXonly. Landlock does not governconnect()to a pathnameAF_UNIXsocket, so give the worker's PostgreSQL role password or certificate authentication, notpeer.[iso8]intests/unit/test_seccomp_hardening.cppruns the real filter in forked children. - No thread exists before hardening; seccomp is installed with
TSYNC(ISO-1, ADR-0082): seccomp filters and Landlock rulesets attach to the calling thread only, so a thread that already exists when they are installed keeps whatever it had. Before this fix the logger's constructor started two writer threads, the worker's firstLOG_INFOconstructed the logger before Landlock and the worker filter were applied, and those two threads were left with only the filter inherited from main, which allowsexecve,open,socketandconnect. A compromised worker thread could aim a signal handler at one of them (rt_sigactionandtgkillare allowed) and run unconfined, reading the master key orexecing a shell. Three controls now close this: - Ordering.
observability::SingleLogstarts no thread in its constructor. Untilstart_writers()is called it writes each line synchronously under a mutex (stdout, and the log file when one is open), so an early message is neither lost nor able to deadlock. The worker and the server callstart_writers()only after their hardening is applied. The server's request pools and its connection dispatcher thread (ADR-0077) are likewise created inserve_until_shutdown, after the hardening self-check has passed. Because Landlock has no thread-sync flag on older kernels, this ordering is the only thing that confines a thread against Landlock. SECCOMP_FILTER_FLAG_TSYNC. Every seccomp filter (main, worker and decoder profiles) is installed with theseccomp(2)syscall andSECCOMP_FILTER_FLAG_TSYNC, so any thread that already exists is confined in the same call. A failure, includingTSYNCreporting a thread it could not synchronise, is fatal; there is no fallback to a per-thread install.- Self-check reads every task.
platform::probe_seccomp_status()reads/proc/self/task/<tid>/statusfor every task and reportsSeccomp: 2andNoNewPrivs: 1only if all of them have both. Reading/proc/self/statusalone reports the thread-group leader and cannot see an unconfined sibling. A task that cannot be read or parsed counts as unconfined.tests/unit/test_worker_hardening_threads.cppcovers both properties in a forked child against the real kernel: a thread created before hardening is killed onexecveand every task reports the confined state, and a logger that logged before hardening has created no thread, so the writers it starts afterwards inherit Landlock and seccomp and cannot open the master key path. A kernel without seccomp or Landlock makes those scenarios SKIP with a message rather than pass. - Worker-specific seccomp + runtime hardening (#319): the worker applies
PR_SET_NO_NEW_PRIVS, drops capabilities, sets resource limits, and installs a stricter seccomp-bpf filter (on top of the inherited server filter) that deniesexecve/execveat/spawn-orientedclone— the worker never execs or spawns, so those syscalls are unreachable in normal operation and kill the worker on attempted abuse. This sequence (apply_worker_hardening()insrc/platform/runtime_hardening.cpp) is Linux-only, gated byfederation.worker.apply_hardening(defaulttrue). Fail-closed on other platforms (0.12.1): on FreeBSD/OpenBSD/NetBSD there is no in-process worker sandbox equivalent to seccomp, and until 0.12.1apply_worker_hardening()silently reported success there regardless — the worker logged "runtime hardening applied" and ran fully unsandboxed. It now returns a fail-closed rejection (worker_hardening_unavailable_decision()) naming the unavailable controls, and the worker refuses to start; theWorkerSupervisortreats this as a crash-looping child and federation degrades to 503 rather than ever accepting unsandboxed federation traffic. An operator who accepts the risk can still run the worker there by settingfederation.worker.apply_hardening=falseexplicitly. - ASan exit-time leak check disabled in the worker (#319): the worker
filter denies
ptrace— an escalation primitive a compromised worker must never possess. AddressSanitizer's exit-time LeakSanitizer suspends all threads withptrace(StopTheWorld) before scanning; withptracedenied the tracer is killed and the scan spins forever, so the worker would never exit and the supervisor'swaitpidwould hang. The worker therefore defines__asan_default_options()returningdetect_leaks=0(guarded to ASan builds, C linkage at global scope). This affects only sanitised test builds — production builds compile the guard out — and only the worker; the main process retains full ASan/LSan coverage, and the worker's correctness is covered by its own unit and integration tests. - WorkerSupervisor isolation: the supervisor thread monitors the child
pid with
waitpid. If the worker exits unexpectedly the supervisor closes the channel and respawns — the main process never acts on a half-open or unauthenticated channel. - PDU write authority: the worker does not write accepted PDUs to the
database directly. It sends a
pdu_ingestcall back to the main process over the IPC channel; only the main process writes to the persistent store and advances the authoritativestream_orderingcounter.
Platform-specific defences¶
Linux¶
Linux receives the richest set of in-process controls.
| Defence | Implementation | Notes |
|---|---|---|
| seccomp-bpf syscall allowlist | src/platform/seccomp_hardening.cpp |
Installed in main.cpp before listeners bind and inside the thumbnail worker, always with SECCOMP_FILTER_FLAG_TSYNC so every existing thread is covered (ADR-0082). |
| Architecture guard | src/platform/seccomp_hardening.cpp |
Filter starts with an AUDIT_ARCH_X86_64 or AUDIT_ARCH_AARCH64 guard and fails closed on unsupported architectures. |
| Fail-closed default | src/platform/seccomp_hardening.cpp |
Unlisted syscalls return SECCOMP_RET_KILL_PROCESS. |
| No new privileges | prctl(PR_SET_NO_NEW_PRIVS, 1, ...) |
Applied by apply_seccomp_filter() and apply_runtime_hardening_controls(). |
| Capability bounding set drop | apply_linux_capability_bounding_set() |
Calls prctl(PR_CAPBSET_DROP, cap, ...) for every capability. |
| Core dump policy | apply_linux_core_dump_policy() |
setrlimit(RLIMIT_CORE, {0, 0}) and prctl(PR_SET_DUMPABLE, 0). |
| Self-check probes | src/platform/hardening_self_check.cpp |
Confirms Seccomp: 2 and NoNewPrivs: 1 for every task under /proc/self/task, PR_GET_NO_NEW_PRIVS, and RLIMIT_CORE == 0. |
| Ignored SIGPIPE | src/platform/signal_hardening.cpp |
platform::ignore_sigpipe() is the first call in every executable's main(). |
| systemd sandboxing | packaging/systemd/merovingian.service |
PrivateTmp=true, ProtectSystem=strict, ProtectHome=true, NoNewPrivileges=true, CapabilityBoundingSet=, SystemCallArchitectures=native, MemoryDenyWriteExecute=true, UMask=0077, RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6, RestrictNamespaces=true, ProtectKernelTunables=/Modules=/Logs=, ProtectControlGroups=, ProtectClock=, ProtectHostname= (ISO-8, ADR-0147), etc. |
The seccomp-bpf filter is deliberately narrow: it allows only the syscalls the
runtime actually needs. The filter is installed in main.cpp before
start_client_server is called, so the database layer (SQLite) runs under the
filter from its first access onwards.
Filesystem syscalls in the allowlist — the filter permits the following filesystem-mutating calls because SQLite requires them for correct operation:
| Syscall | Reason |
|---|---|
ftruncate |
WAL file truncation during checkpoints and journal rollback. |
unlink, unlinkat |
Journal file deletion on commit in DELETE journal mode. |
rename, renameat, renameat2 |
Atomic commit in some SQLite configurations; std::filesystem::rename. |
fstatfs, statfs |
Device sector-size probe during WAL-mode open. |
fallocate |
posix_fallocate(3) path: SQLite pre-allocates database and WAL file space; glibc maps this to fallocate(2) on filesystems that support it. |
The path-based truncate and permission-mutation calls (chmod, fchmod,
fchmodat, umask, mkdir) remain blocked. The service-manager sandbox
(ProtectSystem=strict, writable paths limited to /var/lib/merovingian and
/run/merovingian) bounds what unlink/rename/ftruncate/fallocate can
actually reach.
glibc per-CPU and synchronisation syscalls — allowed because modern glibc
(2.31+–2.35+) calls these from malloc, thread initialisation, process spawning,
and after fork():
| Syscall | Nr (x86-64/aarch64) | Reason |
|---|---|---|
clone3 |
435 | glibc 2.34+ uses clone3 instead of clone for pthread_create and posix_spawn. Not defining __NR_clone3 in the build headers silently omits this entry and causes SIGSYS at the first thread or process creation after seccomp is applied. |
close_range |
436 | glibc 2.34+ posix_spawn uses close_range in the child to close inherited file descriptors before exec. The child inherits this filter, so the entry must be present even though only the child calls it. |
faccessat2 |
439 | glibc 2.33+ uses faccessat2 (Linux 5.8+) for faccessat() calls with AT_SYMLINK_NOFOLLOW or AT_EACCESS flags. getaddrinfo and NSS module probing trigger this on Fedora 36+ hosts. |
rseq |
— | glibc 2.35+ registers a per-thread restartable-sequence area after fork(). glibc 2.36+ also uses rseq inside the malloc per-CPU cache. |
membarrier |
— | glibc 2.31+ issues MEMBARRIER_CMD_PRIVATE_EXPEDITED from the malloc fast path on multi-processor systems. |
getcpu |
— | Returns the running CPU/NUMA node; used by glibc's per-CPU TLS and malloc implementation. |
Runtime-instrumentation syscalls — allowed because sanitiser-instrumented
binaries use them after the filter is installed. The federation worker inherits the
server's seccomp filter across execve, so any syscall the sanitizer runtime
needs in the child must also be present:
| Syscall | Nr (x86-64/aarch64) | Reason |
|---|---|---|
personality |
135 | ThreadSanitizer calls personality(ADDR_NO_RANDOMIZE) in the worker after execve to disable ASLR for deterministic shadow-memory layout. Blocking it kills the child with SIGSYS before the IPC handshake completes. |
personality is gated behind __has_feature(thread_sanitizer) / __SANITIZE_THREAD__
in both the main and worker allowlists (#428): it is compiled into the filter only for
TSan builds. A production build must never carry it — personality(ADDR_NO_RANDOMIZE)
lets a caller with arbitrary code execution disable ASLR, weakening a core exploit
mitigation the platform self-check otherwise enforces.
Build-header caveat — clone3, close_range, and faccessat2 were added in
Linux 5.3, 5.9, and 5.8 respectively. Kernel headers shipped with older build
environments (e.g. Ubuntu 20.04 linux-libc-dev at 5.4) may not define the
corresponding __NR_* macros; a naive #ifdef __NR_clone3 guard would silently
drop the entry. The filter uses an #elif defined(__x86_64__) || defined(__aarch64__)
fallback with the raw numeric constant for each of these three syscalls, paralleling
the pattern already used for SECCOMP_RET_KILL_PROCESS.
FreeBSD / NetBSD / OpenBSD (BSD)¶
Merovingian builds and runs on all three BSDs. OpenBSD and FreeBSD have full in-process sandbox support; NetBSD is served by the portable service-manager profile.
OpenBSD — pledge(2) + unveil(2)¶
apply_runtime_hardening_controls() (called from start_client_server()) first plans
every unveil(2) call with openbsd_unveil_rules(). If any listed path is missing
and so is the directory it would be created in, unveil would fail with ENOENT.
In that case no call is made and the control reports failure. The first unveil
hides the rest of the filesystem and cannot be undone, so a failure partway used to
leave the process seeing only the paths before it (ADR-0162). The calls are:
unveil(path, "rx")for every path infilesystem.read_only_paths(e.g./usr,/etc/merovingian).unveil(path, "rwc")for every path infilesystem.writable_paths(e.g./var/lib/merovingian,/var/run/merovingian,/tmp)./tmpis included because the test harness and runtime scratch utilities create temporary files viastd::filesystem::temp_directory_path(), which resolves to/tmpon OpenBSD.unveil("/etc/ssl", "r")— LibreSSL CA bundle for outbound TLS.unveil(NULL, NULL)— locks the vnode allowlist.pledge("stdio rpath wpath cpath flock inet unix dns proc exec", NULL)— theproc execpromises allowfork()/exec()for the thumbnail worker;unixallows the AF_UNIX IPC channel to the federation worker.
openbsd_pledge_is_active() returns true after step 5. The final startup
hardening self-check runs in run_server() after start_client_server() has
applied the BSD profile, so the snapshot reflects the post-apply state.
FreeBSD — Capsicum cap_enter(2)¶
FreeBSD Capsicum capability mode forbids opening files by path after cap_enter().
The call therefore happens after all resources are open:
apply_runtime_hardening_controls()validates the BSD profile and returns accepted without callingcap_enter().- TCP listeners bind, TLS certificates load, and the federation worker is
spawned via
posix_spawn()(path-based — must run before capability mode). run_server()inmain.cpppre-opens the thumbnail worker binary withopen(path, O_RDONLY | O_EXEC | O_CLOEXEC)and stores the fd inRuntimeMediaConfig::thumbnail_worker_fd.apply_freebsd_capsicum_capability_mode()callscap_enter().- The child process (thumbnail worker) uses
fexecve(fd, argv, environ)instead ofexecv(path, argv)because opening the binary by path is now forbidden.
freebsd_capsicum_is_active() probes cap_getmode(2). The final startup
hardening self-check runs in run_server() after cap_enter() has been called,
so the snapshot reflects the confined state.
NetBSD and other BSDs¶
No in-process sandbox primitives are implemented. The BSD hardening profile is
validated as documented, but apply_runtime_hardening_controls() returns a
mode-gated rejection. Service-manager privilege drop and filesystem restrictions
apply via rc.d scripts.
- The portable
setrlimitgates are validated by the BSD hardening profile, and the thumbnail worker appliesRLIMIT_CPU,RLIMIT_FSIZE,RLIMIT_CORE,RLIMIT_NOFILE, andRLIMIT_AS(RLIMIT_DATAon OpenBSD, which has noRLIMIT_AS). - The thumbnail worker fd sweep uses the capped
fcntl(F_GETFD)fallback on every BSD instead of walking/dev/fd. - Service-manager scripts apply the privilege drop and filesystem restrictions:
- OpenRC:
packaging/openrc/merovingian(command_user=merovingian:merovingian,directory=/var/lib/merovingian). - FreeBSD/NetBSD/OpenBSD rc.d:
packaging/rc.d/merovingianandpackaging/openbsd/rc.d/merovingian(merovingian_user, runtime and state directories owned by the service user).
Portable / other Unix¶
On platforms that are neither Linux nor a recognised BSD, Merovingian uses the
portable hardening profile (default_portable_hardening_profile()). The
profile:
- requires that the hardening plan documents privilege drop, filesystem restrictions, resource limits, memory locking, random source, and signal handling;
- applies no kernel-specific syscalls itself;
- relies on the service manager to drop privileges and confine the filesystem.
Startup hardening self-check¶
src/platform/hardening_self_check.cpp is invoked from src/main.cpp after all
platform hardening controls have been applied (seccomp-bpf, Linux capability
bounding / no_new_privs / core-dump policy, OpenBSD pledge/unveil, FreeBSD
Capsicum). Each check reports one of:
enabled— the defence is active;disabled— the defence was explicitly turned off (hard failure);unknown— the probe could not confirm the defence (e.g. static binary, unsupported kernel).
run_server() refuses to start serving traffic unless every check reports
enabled. HardeningSelfCheck::is_ready() returns true only when there are no
blockers, and production_blockers() lists every check that is not enabled.
Packaging and deployment hardening¶
The packaging scripts request the same linker hardening that meson.build
advertises:
scripts/build-deb.shandscripts/build-rpm.shpass-pie -Wl,-z,relro -Wl,-z,now.scripts/build-static-linux.shpasses-static-pie -Wl,-z,relro -Wl,-z,nowand verifies the resulting binary has no dynamic interpreter.- The RPM specs (
packaging/rpm/merovingian.spec,packaging/rhel/merovingian.spec,packaging/opensuse/merovingian.spec) pass the same flags and strip conflicting_FORTIFY_SOURCEdefinitions from the distribution's%optflags.
Every packaging path also asks for an optimised build (0.12.26, audit OPS-5).
Meson builds debug (-O0) when no buildtype is given, _FORTIFY_SOURCE is
only defined when optimising, and the startup self-check refuses to start a
binary whose FORTIFY_SOURCE check is not enabled. So:
scripts/build-deb.sh,scripts/build-freebsd-pkg.sh,scripts/build-netbsd-pkg.sh,scripts/build-openbsd-pkg.sh,scripts/build-static-linux.shand theDockerfilepass--buildtype=release. The RPM specs use the distribution%mesonmacro, whose%optflagssupply-O2.meson.buildkeeps Meson'sdebugdefault for development and test builds (ADR-0148), and warns at configure time when hardening is on atoptimization=0.tests/tooling/test_packages_workflow.pyfails if any of those scripts, or the Dockerfile, configures Meson without--buildtype=release.
The Dockerfile:
- pins both stages'
debian:trixie-slimbase by the digest of its multi-architecture index, so a re-pushed tag cannot change the image; - builds with libpq, OpenSSL, libpng and libturbojpeg, so the thumbnail worker is built rather than silently skipped;
- ships
merovingian-server,merovingian-db-migrate, and both workers (/usr/libexec/merovingian/merovingian-fed-worker, the path the server spawns by default, andmerovingian-thumbnail-worker), with their runtime libraries, and fails the image build iflddfinds an unresolved library in any of them; - runs as the unprivileged
merovingianuser. A non-root process cannot drop its own capability bounding set, so the container must be started with--cap-drop=ALL(and should be started with--security-opt=no-new-privileges), or thecapability boundingcheck staysunknownand the server refuses to start. The systemd unit gets the same result fromCapabilityBoundingSet=.
scripts/check-startup-hardening-gate.sh proves a packaged binary actually
passes the gate. It boots the server against a throwaway SQLite config bound
to loopback with a fresh owner-only master key, waits for the final self-check,
and passes only when every check reports enabled, the number of enabled
checks equals the number the server says it ran, readiness is
ready=true blockers=0, the federation worker is spawned and still running a few
seconds later (its own seccomp and Landlock policy applies after it starts, and a
worker killed by it would otherwise go unnoticed while the supervisor restarts it), and
the server then exits 0 on SIGTERM. Unless
--federation-worker is given it leaves the worker path to the binary's
built-in libexec default, so a pass also proves the package installed the
worker where the server looks for it. Its decisions are covered by
tests/tooling/test_startup_hardening_gate.py against a fake server.
CI gates¶
Hardening is exercised by automated tests:
tests/unit/test_seccomp_hardening.cppasserts the fail-closed default action, the architecture guard, that SQLite journal syscalls are allowed, that privilege-mutation syscalls (chmod, fchmod, fchmodat, umask, mkdir, truncate) remain denied, and thatclone3(435),close_range(436), andfaccessat2(439) are always present in the compiled filter regardless of build-time__NR_*macro availability.tests/unit/test_file_descriptor.cppexercisesFileDescriptor::set_cloexec()andclose_all_file_descriptors_except().tests/unit/test_media_thumbnailer.cppcovers the CLOEXEC pipe path and the sandboxed worker round-trip.tests/unit/test_runtime_hardening.cppvalidates profile accept/reject logic, including (0.12.1)worker_hardening_unavailable_decision()— the pure, syscall-free decision builderapply_worker_hardening()falls back to on non-Linux platforms — proving it is fail-closed rather than a silentaccept(), plus a non-Linux-only scenario exercisingapply_worker_hardening()itself end to end.tests/integration/test_server_startup_hardening_flow.cppspawns the realmerovingian-serverbinary and verifies it either starts under full hardening or refuses to start when a control cannot be enabled. As of 0.12.1 the "starts under full hardening" branch also issues a real HTTP GET over a real TCP socket against the spawned process's client listener and asserts a genuine 200 response, plus a post-shutdown connect-refused check — proof the process actually serves traffic, not merely that it stays alive. The test skips in environments that cannot satisfy every control (for example, adebugMeson build omits_FORTIFY_SOURCE, or a non-root Linux process lacksCAP_SETPCAPto drop the capability bounding set). To exercise the live-serving path, build with--buildtype debugoptimizedorrelease(or--profile hardened, which impliesrelease+-Dhardening=true) and run the test as a non-root user that retainsCAP_SETPCAP(ambient capability,sudo setcap cap_setpcap+epon the binary, or a suitable container/security profile). CI gate added in 0.12.1: theubuntu-hardened-listener-coveragejob in.github/workflows/ci.ymlbuilds thehardenedprofile and grantsmerovingian-serverCAP_SETPCAPviasudo setcap(GitHub-hosted runners provide passwordless sudo) specifically so this scenario reaches its live-serving assertions in CI instead of WARN-skipping, which every other CI job still does (root containers fail the privilege-drop check instead).scripts/check-elf-hardening.sh(0.12.1) statically inspects a built binary's ELF headers (PIE,PT_GNU_RELRO,DT_BIND_NOW, non-executablePT_GNU_STACK) and fails closed if any is missing — the CI-time counterpart to the runtime ELF probe above, needing no live process so it runs the same whether the job is root, non-root, or containerized. Run against every shipped binary in theubuntu-hardened-listener-coverageCI job and per-platform in.github/workflows/release.ymlviascripts/collect-release-evidence.sh.scripts/check-startup-hardening-gate.sh(0.12.26, audit OPS-5) runs the real startup gate against what is shipped. In.github/workflows/packages.ymlthedebanddebian-pkgjobs install the built.deband run it as themerovingianuser undersetpriv --bounding-set=-all, and thedocker-imagejob builds theDockerfileand runs it inside the image with--cap-drop=ALL --security-opt=no-new-privileges. The BSD package jobs do not run it yet.
Because seccomp/pledge/unveil are permanent in-process, the test suite sets
MEROVINGIAN_TEST_DISABLE_HARDENING=1 when invoking meson test. The build
scripts export this variable automatically; direct invocations of the test binary
(such as the PostgreSQL integration workflow) must set it manually. Production
server binaries never see this variable and always apply the platform profile.
What is intentionally deferred¶
The following controls are implemented as service-manager policy rather than in-process syscalls:
- In-process privilege drop (
setresgid/setresuid) — the server must never run as root; the service manager supplies a dedicated user. - In-process Linux filesystem confinement (Landlock) for the main
merovingian-serverprocess — service-manager sandboxing (systemd/OpenRC/FreeBSD rc.d) enforces filesystem restrictions; the Merovingian hardening profile documents these requirements. The federation worker is the exception: as of ADR-0062 part 3 (0.12.13) it applies its own in-process Landlock ruleset (see "Out-of-process federation worker IPC security" above) — the worker is the more exposed, more frequently restarted process, so it gets an in-process control the main process still relies on the service manager for. - A platform-specific in-process sandbox for the federation worker
(
merovingian-fed-worker) on FreeBSD/OpenBSD/NetBSD — unlike the main server, the worker has no pledge/unveil- or Capsicum-equivalent hardening sequence there. As of 0.12.1 this is a documented, fail-closed gap rather than a silent one:federation.worker.apply_hardening=true(the production default) makes the worker refuse to start on those platforms instead of running unsandboxed. An operator can opt into running an unsandboxed worker there withfederation.worker.apply_hardening=false, relying solely on service-manager confinement.
OpenBSD pledge/unveil and FreeBSD cap_enter() are fully wired and exercised
in CI. The startup readiness gate will not pass until every hardening check
reports enabled. See todos/capability-gaps.md for
the current list.