This is the list an auditor reads first. One row per control: what it is, its status (in place with its date and the card that brought it, or planned with the card that will), its owner as a role, and its evidence — a path in the platform’s repository or a page on the operator console. The source of this page is docs/security-controls.md in the platform’s repository; a release that changes a control updates it in the same release.
The roles: the platform operator (Beegait on the hosted platform; on your own server, your operator — the same controls apply, with the bundle’s files), the account owner, the workspace manager, the runner’s owner (the person or team whose machine runs the helper program) and the vendor (Beegait, for the offline signing keys). The plan behind the planned rows is the security workstream of the product’s own project record; each row names its card.
Host
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| TLS front | Caddy is the only public-facing service: automatic HTTPS (Let’s Encrypt, auto-renew) for the domain, everything else proxied to the app on the compose network. | in place — 2026-08-06, card MV2-LAUNCH-VPS (34) | platform operator | deploy/Caddyfile (self-hosted: deploy/selfhosted/Caddyfile) |
| The forwarded client address | The app believes X-Forwarded-For only from Caddy’s fixed address on the edge network; any other peer is seen at its own address. The app port is loopback only, checked by the deploy preflight. | in place — 2026-10-22, card MV2-SEC-FORWARDED-IP-TRUST (ships in the Phase S release) | platform operator | compose.prod.yml, cloud/access.py (client_ip), tools/compose_validate.py (app_port_problem, edge_problem, binding_problem) |
| Outbound connections | Every ssh the cloud’s git starts goes through one egress rule: no private, local or metadata address, resolved once (DNS-rebinding proof); git follows no https redirect; postgres and minio sit on an internal network with no route out. | in place — 2026-10-22, card MV2-SEC-APP-EGRESS (ships in the Phase S release) | platform operator | cloud/egress.py, Dockerfile (ssh_config.d/beegait-egress.conf), compose.prod.yml (networks.data) |
| Browser security headers | Every page of the app carries a Content Security Policy (same-origin sources, no plugins, no form to another site, no framing by another site; the operator console is never framed), nosniff and a same-origin referrer policy; the TLS front adds HSTS. The two boxed paths of the hosted docs site and the public links keep their own stricter policy. | planned — built 2026-10-21 (card MV2-SEC-CLOUD-NO-INLINE-DATA) | platform operator | cloud/secheaders.py, deploy/Caddyfile |
| No exposed database or object-store port | Postgres and MinIO have no host port mapping at all; in production the app’s own port binds loopback (COMPOSE_APP_PORT=127.0.0.1:8100) — only Caddy reaches it. |
in place — 2026-07-25, the ransom-bot lesson (card 34) | platform operator | docker-compose.yml (the NEVER lines under postgres and minio), compose.prod.yml |
| PID 1 init | tini reaps git’s detached grandchildren; without it zombies filled the container’s task limit in ~7 h (the 2026-09-07 outage). |
in place — 2026-09-07 | platform operator | docker-compose.yml (init: true) |
| Firewall | ufw: SSH + 80/443 and nothing else, default deny incoming. |
in place — 2026-08-06, card MV2-LAUNCH-VPS (34) | platform operator | tools/launch_vps.md §1 |
| sshd keys only | PermitRootLogin no, PasswordAuthentication no; the deploy user holds the provisioning key. |
in place — 2026-08-06, card MV2-LAUNCH-VPS (34) | platform operator | tools/launch_vps.md §1 |
| Unattended security patches | unattended-upgrades applies the OS security updates by itself. |
in place — 2026-08-06, card MV2-LAUNCH-VPS (34) | platform operator | tools/launch_vps.md §1 |
| Disk doors | Every publish carries the disk-usage gate lines; a full disk is refused before the deploy, not discovered after. | in place — 2026-09-13, card MV2-PHX-DISK-GATE (291) | platform operator | tools/launch_vps.md §7b, tools/phx_publish.sh |
| The deploy preflight | compose_validate.py --prod refuses a deploy whose served artifacts lack their hash or signature, and — since card 364 — a malformed admin IP allowlist, the app port binding and the edge network. |
in place — 2026-09-03 (cards 172 · 177), 2026-09-27 (338), 2026-09-28 (364), 2026-10-22 (MV2-SEC-FORWARDED-IP-TRUST) | platform operator | tools/compose_validate.py, tools/phx_publish.sh |
Access
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| A separate admin realm | admin_users / admin_sessions share nothing with the account tables: an account session can never reach /admin, an admin session never impersonates a user. |
in place — 2026-07-27, card 28 | platform operator | cloud/admin.py (the module docstring), migrations/007_admin.sql |
| MFA on every admin, mandatory | Every console sign-in is an argon2id password AND a valid TOTP code (RFC 6238, stdlib) — no admin account exists without the second factor. | in place — 2026-07-27, card 28 | platform operator | cloud/admin.py, cloud/mfa.py |
| The operator grant (least privilege) | An admin account is a GRANT on a Beegait account: no password of its own, the person’s own password and authenticator open the console, disabled_at is the grant’s kill switch; no open signup, the CLI bootstrap is the rescue door only. |
in place — card 299, 2026-09-14 | platform operator | cloud/admin.py (the card 299 block), /admin/admins |
| 12-hour admin sessions | The operator realm’s sessions expire after 12 hours (account sessions: 14 days); sessions are listed with IP and last sign-in and revocable one by one. | in place — 2026-07-27 (card 28), 2026-08-31 (MV2-ADMIN-CONTROL, 118) | platform operator | cloud/admin.py (ADMIN_SESSION_HOURS), /admin/admins |
| The admin IP allowlist | With MC_ADMIN_IP_ALLOWLIST set, /admin/* answers 404 to every client outside the list (invisible, in front of the MFA login). A malformed entry fails closed: the console answers 404 to everyone, one log line names the entry, and the deploy preflight refuses the value before the box sees it. |
in place — 2026-08-31, card MV2-ADMIN-CONTROL; fail-closed 2026-09-28, card 364 (it failed open before) | platform operator | cloud/access.py (_admin_ip_allowed), tools/compose_validate.py (allowlist_problem), .env.compose.example |
| A second factor for every user | TOTP offered to every account, “remember this PC” for 5 days, ten one-time recovery codes at enrolment, a forced re-enrolment door. | in place — 2026-09-09 (cards 238-239), 2026-09-14 (310-313) | account owner (each person enrols) | cloud/mfa.py, migrations/042_user_mfa.sql |
| Sign-in brute-force window + captcha | Failed sign-ins are remembered 15 minutes per IP and per email; the picture captcha is asked after a failure, and platform-wide while the spike rule trips (login_spike_failures on the platform row, the console shows a banner). |
in place — 2026-09-09 (card 239), 2026-09-27 (MV2-CAPTCHA-WHEN, MV2-SIGNUP-SAFETY) | platform operator | migrations/043_login_failures.sql, cloud/captcha.py, cloud/admin.py (spike_banner) |
| argon2id passwords + the password policy | Passwords hashed with argon2id; tokens stored only as sha256; ONE ordered password rule list every set-password door enforces. | in place — 2026-07-21 (card 07, MV2-CLOUD-TENANCY-AUTH), 2026-09-27 (MV2-SIGNUP-RULES) | platform operator | cloud/security.py |
| Secure session cookies | The session cookie carries the Secure flag behind TLS (MC_COOKIE_SECURE=true in production). |
in place — 2026-08-06, card MV2-LAUNCH-VPS (34) | platform operator | cloud/config.py, compose.prod.yml |
| The private-preview curtain | MC_ACCESS_CODE gates every page behind one shared code during a test phase; rotating the value re-prompts every device. |
in place — 2026-08-06, card 35 (MV2-ACCESS-CURTAIN) | platform operator | cloud/access.py (access_gate) |
| Workspace roles | Owner · manager · member · viewer — the doors that change money or data (upgrade, audit export, public links, the agent rules) are owner/manager only. | in place — 2026-08-31 (card 116), 2026-09-14 (302), 2026-09-25 (MV2-AUDIT-EXPORT) | account owner | cloud/auth.py, cloud/upgrade.py, cloud/share.py |
| No name runs as code | A name a person, a card or a machine chooses never lands inside an inline handler of a page: buttons carry data in data-attributes and every page escaper escapes quotes; a check refuses the pattern. | planned — built 2026-10-21 (card MV2-SEC-CLOUD-NO-INLINE-DATA) | platform operator | tests/noinlinedata_test.py |
| Cross-site writes refused | A change sent to the app from a page of another website, or from a sibling address of the same site, is refused before it runs: every request that is not a read must come from a page the app served itself (the browser’s Sec-Fetch-Site, else Origin against the app’s own address); scripts and machines that send neither header keep working. | planned — built 2026-10-22 (card MV2-SEC-CSRF-ORIGIN-CHECK) | platform operator | cloud/csrf.py, tests/csrf_test.py |
Secrets
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| The vault and its master key | Per-row AES-256-GCM sealing (a per-workspace DEK wrapped by ONE deployment master key from the environment) for the repo deploy keys and the git-host OAuth secrets; a secret that reaches a log line anyway is masked by the row below. | in place — 2026-07-25, card MV2-KEY-VAULT (25) | platform operator | cloud/vault.py |
| A secret never reaches a log line | Every log handler of the app and of the site-builder process masks the known secret shapes — provider and git-host tokens, runner tokens, bearer values, secret headers, credentials inside a URL, the token · code · state · ticket query values, share links, private keys — in the message, in its arguments and in the traceback; a print() cannot write one either (both processes’ stdout and stderr go through the same table). A password-reset click now reads token=***redacted*** in the access log. |
planned — built 2026-10-22 (card MV2-SEC-LOG-REDACTION, audit SEC-8) | platform operator | cloud/logsafe.py, tests/logsafe_test.py |
| No AI provider key stored for an account | The provider-key lanes were retired: agents run on the credentials the runner machines hold; the platform holds no provider key of its own, and the free trial runs on the person’s own runner too (the trial’s platform lane retired by card MV2-TRIAL-RUNNER-FIRST, 2026-09-24). | in place — card 283, 2026-09-12 | platform operator | cloud/vault.py (the docstring), the site’s /security/ |
| The master key in a KMS or HSM | The deployment master key leaves the environment file for a managed KMS; every DEK re-wrapped; every unseal audited. | planned — cards 346-347 (S1) | platform operator | Story MV2-SEC-MASTER-KEY-KMS |
| No code-reading credential on the platform | The GitHub App drops to Metadata: Read (348-349, S2); deploy keys move to the runner (350-351, S3); broad personal tokens refused at connect (352, S4); runner-only repositories as a documented option (353, S5); Bitbucket listed from the runner (361-362, S13). | planned — cards 348-353, 361-362; 362 built 2026-09-30 (migration 081: no Bitbucket credential on the platform), in place with the release that carries it | platform operator | Stories S2-S5, S13 |
| Signed releases | Every engine wheel and runner package is signed OFFLINE by the vendor’s release key; the self-update offer carries the signature beside the sha256; the publish refuses an unsigned artifact. | in place, the cloud half — card 338, 2026-09-27; the runner’s verifier (339) and the OS package signing (340) planned | the vendor | cloud/relsig.py, tools/release_sign.py, tools/compose_validate.py |
| Signed licences | A self-hosted licence is an Ed25519-signed file; the image verifies it with the public key it carries. | in place — 2026-08-31 (card 124, MV2-SH-LICENSE), 2026-09-27 (MV2-SH-KEY-BAKED) | the vendor | cloud/license.py, deploy/license_pubkey.txt, tools/license_issue.py |
| Secrets never in the repository | .env.compose is gitignored; ${VAR:?} fails loud when one is missing; the offline keys (licence, release, backup) live outside every checkout. |
in place — 2026-08-06 (card 34), 2026-09-27 (338) | platform operator | docker-compose.yml, .env.compose.example |
Audit
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
admin_audit |
Every mutating console action (who / when / old → new), keyed by the account it touched; readable at /admin/audit with filters. |
in place — 2026-07-27 (card 28); the viewer 2026-08-31 (MV2-ADMIN-CONTROL, 118) | platform operator | cloud/admin.py (the audit viewer), /admin/audit |
org_audit |
The workspace’s own changes (run limits, public links, upgrade requests, the grace and dormant flips the platform writes itself); the account reads it through the audit export (CSV / JSON, a Premium feature) together with the console’s rows about the account. | in place — 2026-09-25, card MV2-AUDIT-EXPORT | account owner / workspace manager | cloud/auditexport.py, GET /api/org/audit.csv |
git_host_audit · repo_audit · hub_mirror_audit · hub_lifecycle_audit |
Written on every git-host connect or refusal, repository change, mirror push and hub lifecycle step. Writers only — no viewer yet. | in place — the writers since 2026-07-22 (card 11) through 2026-09-13 (card 257); a viewer planned — a follow-up card under Story MV2-SEC-MIRROR-KEY-HOST | platform operator | cloud/githost.py, cloud/repos.py, cloud/mirror.py, cloud/hubs.py |
vault_audit |
Every unseal of a sealed secret written down. | planned — card 346 (S1) | platform operator | Story MV2-SEC-MASTER-KEY-KMS |
| The runner’s local audit log | What every job sent back, on the machine that ran it. | planned — card 354 (S9) | runner’s owner | Story MV2-SEC-CAP-AND-LOG |
Data
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| Nightly backups | The files (hub bare repositories + blobs) and pg_dump -Fc of the database, 14 dated folders, the restore drill run once then quarterly. They land PLAIN on the host until card 365 is live there (the next row). |
in place — 2026-08-06 (MV2-LAUNCH-VPS), 2026-09-13 (card 258); encryption planned — built 2026-09-29 (card 365) | platform operator | tools/backup_storage.sh, tools/launch_vps.md §8 |
| Encrypted backups | Every backup artifact encrypted on its way to disk (age) to a recipient key whose private half stays offline with the other vendor keys (a hosted backup without a recipient is refused); the restore script decrypts and deletes the copied key; a self-hosted box’s pre-update dump takes the same door as an option. |
planned — built 2026-09-29 (card MV2-SEC-BACKUP-ENCRYPT, 365); in place once phx holds the recipient and the restore drill (TC5) has run | platform operator | tools/backup_storage.sh, tools/restore_storage.sh, cloud/updater.py (_write_dump), tests/backup_test.py, tools/launch_vps.md §8 |
| Retention limits on trails and chat | Job trails and chat purged after the plan’s window. | planned — card 358 (S11) | platform operator | Story MV2-SEC-RETENTION |
| A chat is its asker’s | A chat job (its question, reasoning trail and answer) is served to the person who asked it and nobody else — a manager included: another person’s chat is left out by the SQL of every person reader (queue.CHAT_PRIVATE_SQL, passed as viewer_id), its id answers 404, its thread never resumes for someone else; the hub lock still counts it (“another person’s chat”, no words) and a manager may still stop it. A new person-facing read of jobs passes the viewer (_job_rows(viewer_id=…) / queue.chat_visible) — viewer_id=None is for machine readers only. |
in place — 2026-10-06, card MV2-CHAT-PRIVATE | platform operator | cloud/queue.py (chat_visible), tests/chatprivate_test.py |
| Per-workspace encryption of hub data | The hub files and the database text sealed per client. | planned — cards 356-357 (S10) | platform operator | Story MV2-SEC-PER-CLIENT-ENCRYPTION |
| The record is git | Every hub is a git repository: its history is the audit of the record, and a one-way mirror to the account’s own GitHub or Bitbucket keeps a full copy in their hands. | in place — 2026-07-22 (card 11), 2026-08-25 (MV2-HUB-HOSTING, 76) | workspace manager | cloud/hubs.py, cloud/mirror.py |
| The hub mirror write key | The cloud’s own write key into the client’s repository (the mirror): a “Rotate key” door and a 90-day schedule register a new key at the host, push once with it and de-register the old one by its id (a pasted key rotates in two steps, the person confirming); every push that opens the key writes a key_used row. |
planned — built 2026-09-29 (card MV2-SEC-MIRROR-KEY-ROTATE, 363); in place with the release that carries migration 079 | platform operator | cloud/mirror.py (rotate, rotate_due), tests/mirrorkey_test.py |
| Trash before delete | A deleted hub sits in the Trash and is purged after its TTL by the site builder’s hourly tick — never gone on one click. | in place — card 257, 2026-09-13 | workspace manager | cloud/hubs.py (purge_tick), cloud/sitebuilder.py |
| Dormant trials | An ended trial nobody signed into is warned by mail, then removed by the sweep. | in place — 2026-09-25, card MV2-TRIAL-DORMANT-CLEANUP | platform operator | cloud/dormant.py |
| Data-protection statement | What is stored, where, for how long — the public statement. | in place — 2026-09-05, the site’s page | platform operator | the site’s /data-protection/ |
Code protection
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| The image carries the control plane compiled | The cloud package is compiled per module in a build stage (Nuitka); the image — ONE for the hosted platform and every self-hosted box — holds extension modules, the package’s __init__.py and the launch stubs of its python -m doors: no module source and no bytecode cache, in no layer. The engine inside the image is still the pure wheel until its Linux wheel is compiled (card MV2-SEC-ENGINE-WHEEL-COMPILED-MAC-LINUX). |
planned — built 2026-10-02 (card MV2-SEC-SELFHOSTED-IMAGE-COMPILED); in place with the release that ships the compiled image | the vendor | Dockerfile, /healthz build, tools/compose_validate.py (image_problem) |
| The Windows runner ships compiled | The engine wheel inside the MSI is compiled per module (every module a native one; what stays source: the package’s __init__.py files, __main__.py and two launch stubs); the update lane is unchanged. Self-update serves each runner the signed wheel of its own platform: the runner announces the Python it runs on, the cloud picks the compiled wheel of that tag from a per-platform table and falls back to the pure wheel; every served wheel carries its own sha256 and release signature. |
planned — built 2026-10-02 (card MV2-SEC-ENGINE-WHEEL-COMPILED-WIN, card MV2-SEC-PLATFORM-WHEEL-OFFER); in place with the release whose MSI carries the compiled engine and whose offer serves the compiled wheel | the vendor | tools/compile_build.py --wheel (engine), tools/build_runner_msi.ps1, cloud/runners.py (engine_wheel_table), tools/launch_vps.md §6 |
| The macOS and Linux runners ship compiled | One compiled wheel per platform, offered to the runner that matches: the pkg’s engine is compiled with its bundled CPython for arm64 and Intel (every native module codesigned inside the signed, notarized app), the deb carries a manylinux wheel per CPython minor (3.10 · 3.11 · 3.12) and installs the one the box’s Python fits; the pure wheel stays the fallback for a Python outside that range. | planned — built 2026-10-03 (card MV2-SEC-ENGINE-WHEEL-COMPILED-MAC-LINUX; the workflow’s compiled lane); in place with the release that ships the compiled pkg and deb |
the vendor | Story MV2-SEC-COMPILED-DISTRIBUTION · .github/workflows/runner-packages.yml |
| Constants and data files hidden in the binaries | The commercial edition of the compiler: constants encrypted, data files embedded, tracebacks stripped. | planned — card MV2-SEC-NUITKA-COMMERCIAL-KEY (S15) | the vendor | Story MV2-SEC-COMPILED-DISTRIBUTION |
| The shipped wheel stripped, the pages minified | No docstrings, tests or docs in the wheel; the board’s and the runner window’s JavaScript and CSS minified. | planned — card MV2-SEC-WHEEL-STRIP-MINIFY (S15) | the vendor | Story MV2-SEC-COMPILED-DISTRIBUTION |
Runner
| Control | What it is | Status | Owner | Evidence |
|---|---|---|---|---|
| Outbound only | The runner long-polls the platform; it opens no inbound port and accepts no incoming connection. | in place — 2026-07-22, card MV2-RUNNER-CONNECT (13) | runner’s owner | maistro/runner.py (the engine), the site’s /security/ |
| Typed jobs | A runner runs only the job types the engine declares; the claim hands it typed parameters, never a shell line. | in place — 2026-07-22, card MV2-JOB-QUEUE (12) | platform operator | cloud/jobtypes.py, cloud/queue.py |
| Pairing with consent, revoke in one click | A runner is paired once with a short code, serves what its owner consented to (the account, its workspaces) and the console revokes it in one click. | in place — 2026-07-22 (MV2-RUNNER-CONNECT), 2026-08-11 (MV2-RUNNER-CONSENT, 49), 2026-07-27 (card 28) | runner’s owner / platform operator | cloud/runners.py, /admin/runners |
| The version floor | MC_MIN_RUNNER_VERSION refuses an outdated runner with the one-liner that updates it. |
in place — 2026-08-22, card MV2-RUNNER-VERSION-FLOOR (63) | platform operator | cloud/config.py, compose.prod.yml |
| The self-update verified | The runner verifies the offer’s signature against the release key it pins by value; an unsigned or badly signed offer is refused with one sentence. | planned — card 339 (S6) | the vendor | Story MV2-SEC-SIGNED-RELEASES |
| A real person behind every job + the runner allowlist | Every job carries the requesting person’s proof; a workspace lists the runners allowed to serve it. | planned — cards 341-343 (S8) | workspace manager | Story MV2-SEC-JOB-PROOF-ALLOWLIST |
| Client-controlled updates | The machine’s own update policy decides when a runner updates; the console only shows it. | planned — cards 344-345 (S7) | runner’s owner | Story MV2-SEC-UPDATE-POLICY |
| Cap and log what comes back | An outbound cap on what a job may send, and the local audit log of what it sent. | planned — cards 354-355 (S9) | runner’s owner | Story MV2-SEC-CAP-AND-LOG |
| No code quotes | A workspace setting that forbids code quotes in what its agents send, enforced on the runner. | planned — cards 359-360 (S12) | workspace manager | Story MV2-SEC-NO-CODE-QUOTES |
