Kernl

0.2.0 · · Release on GitHub

Kernl 0.2.0: The Docker Stack Stops Running Unauthenticated

Kernl illustration: the mascot surrounded by holographic data panels

This Kernl release is almost entirely security, and the main problem sat on the path everybody takes: the Docker quick start.

Loopback is not a trust boundary

docker-compose.yml shipped KERNEL_ALLOW_UNAUTH=1, justified by publishing the dashboard on 127.0.0.1. That reasoning does not hold against a browser.

Combined with a wildcard Access-Control-Allow-Origin on every response, any page the user had open could read — and write — the entire kernel cross-origin, including the MCP tool surface. A port being local does not stop the user's own browser from being the one making the request.

The quick start now runs with the token the kernel generates on first boot.

CORS by refusal

Access-Control-Allow-Origin is sent only for an origin explicitly listed in CORS_ALLOWED_ORIGINS. An empty list means same-origin only, which is what the bundled dashboard needs.

Previously an empty list meant *, and a non-matching origin was answered with the first configured origin instead of a refusal.

License recovery handed out the license

GET /api/license/lookup returned the full license JWT — the download credential for every extension that customer owns — to anyone who knew their email address. As a bonus, its 404 confirmed which addresses belonged to customers and which didn't.

It now mails the license to the address on file and answers 202 identically either way, whether or not the address belongs to anyone.

Torrents is also gated at runtime. The paid extension shipped with no license check on its MCP tools or its /api/torrents/* routes; the only protected piece was the bundle download.

Finally, the first-boot auth token is handed to the browser through the URL fragment, which is never sent to the server, instead of through a query string.

Backups captured the wrong database, and said so cheerfully

backup.sh copied the repo's ./data/kernel.db — usually a stale leftover — rather than the database inside the Docker volume. It used cp on a WAL-mode database open by the kernel. And it exited 0 when it found nothing.

It now snapshots through SQLite (VACUUM INTO), verifies the result before calling it a backup, and fails loudly. It also captures .kernel-encryption-key, without which encrypted secrets in a restored database are unrecoverable.

Restores had the mirror-image problem: restore.sh copied the database into the repo's ./data even on Docker deployments, skipped the encryption key, and reported "starting fresh" when the archive held no database at all. It now requires an explicit --docker or --native target and refuses an empty archive.

Two things that reported success while doing nothing

  • Paid licenses were rejected on every non-Docker install. The kernel expected an issuer of issuer.mtwkernel.com while licenses are minted by issuer.lifekernl.com, and only docker-compose.yml set the override. rpm, deb, dmg and msi installs could not activate a valid key.
  • Code signing never ran, with or without certificates. The release workflow's signing steps were gated on if: env.X != '', with X defined in that same step's env: block, which is not in scope for that step's own condition. Every release published unsigned artifacts and reported success.

The kernel also bound to loopback even with authentication enabled: loadConfig() resolved the bind before bootstrap generated the auth token, permanently rewriting an explicit 0.0.0.0. That decision now happens where the final token is known.

And first run on a native install dead-ended at a login screen asking for a token the user had never been shown. The macOS and Windows launchers now hand it over; the Linux launcher prints where to find it.

How to upgrade

curl -fsSL https://lifekernl.com/install.sh | sh

If you're coming from 0.1.x on the Docker stack, check your CORS_ALLOWED_ORIGINS before upgrading: an empty list no longer means *. Full detail is in the CHANGELOG.

Changelog

Security

  • The default Docker stack no longer runs unauthenticated. docker-compose.yml set KERNEL_ALLOW_UNAUTH=1, justified by publishing the dashboard on 127.0.0.1. Loopback is not a trust boundary against a browser: combined with a wildcard Access-Control-Allow-Origin on every response, any page a user had open could read — and write — the entire kernel cross-origin, including the MCP tool surface. The quick start now runs with the token the kernel generates on first boot.
  • CORS is deny-by-default. Access-Control-Allow-Origin is sent only for an origin explicitly listed in CORS_ALLOWED_ORIGINS; an empty list means same-origin only, which is what the bundled dashboard needs. Previously an empty list meant *, and a non-matching origin was answered with the first configured origin instead of a refusal.
  • License recovery no longer hands out license keys. GET /api/license/lookup returned the full license JWT — the download credential for every extension a customer owns — to anyone who knew their email address, and its 404 confirmed which addresses were customers. It now mails the license to the address on file and answers 202 identically either way.
  • Torrents is gated at runtime. The paid extension shipped with no license check on either its MCP tools or its /api/torrents/* routes; only the bundle download was protected.
  • The auth token generated on first boot is handed to the browser through the URL fragment, which is never sent to the server, instead of a query string.

Fixed

  • Backups captured the wrong database, and said so cheerfully. backup.sh copied the repo's ./data/kernel.db — usually a stale leftover — rather than the database inside the Docker volume, used cp on a WAL-mode database open by the kernel, and exited 0 when it found nothing. It now snapshots through SQLite (VACUUM INTO), verifies the result before calling it a backup, and fails loudly. It also captures .kernel-encryption-key, without which encrypted secrets in a restored database are unrecoverable.
  • Restores wrote where nothing reads. restore.sh copied the database into the repo's ./data even on Docker deployments, skipped the encryption key, and reported "starting fresh" when the archive had no database. It now requires an explicit --docker / --native target and refuses an empty archive.
  • Paid licenses were rejected on every non-Docker install. The kernel expected an issuer of issuer.mtwkernel.com while licenses are minted by issuer.lifekernl.com, and only docker-compose.yml set the override — so rpm, deb, dmg and msi installs failed to activate a valid key.
  • Code signing never ran, with or without certificates. The release workflow's signing steps were gated on if: env.X != '' with X defined in the same step's env: block, which is not in scope for that step's own condition. Every release published unsigned artifacts and reported success.
  • The kernel bound to loopback even with authentication enabled: loadConfig() resolved the bind before bootstrap generated the auth token, permanently rewriting an explicit 0.0.0.0. The decision now happens where the final token is known.
  • First run on a native install dead-ended at a login screen asking for a token the user had never been shown. The macOS and Windows launchers now hand it over; the Linux launcher prints where to find it.
  • docs/DEPLOYMENT.md documented the Neo4j backup against a volume name that does not exist, which makes docker run -v create an empty one and archive nothing, successfully.