Skip to content

Security: Inovello/lifeos

Security

SECURITY.md

Security

LifeOS is a single-user application. It runs on a machine you own, talks to a language model you point it at, and is opened from your own devices. This document says what the software protects, what it does not, and what you are trusting when you use it. Read it once before you expose the app to anything beyond your own computer.

The shape of the thing

One process. It serves the web app and the API on one port, keeps everything in one SQLite file under app.data_dir, and reaches the network only for things you run or choose yourself: an OpenAI-compatible chat endpoint (the extraction model); optionally Google Calendar and the Web Push services of your browser vendor; and Open Food Facts, when you scan a product barcode.

There are no accounts. There is one shared token: a string you set in config.local.yaml and enter once in each browser. Every API request must carry it. That is the whole authentication system, and it is deliberately no more than that: the application is for one person on their own network.

What a fresh install does

  • Binds to loopback only (server.host: 127.0.0.1). Nothing on your network can reach it until you change that.
  • Refuses to bind beyond loopback without a token. Setting server.host: 0.0.0.0 with app.shared_token: null is a configuration error and the process will not start. If you really want an unauthenticated app on your network you must say so by name (server.allow_open_bind: true), and you should not.
  • Serves plain HTTP. Browsers only expose the microphone and allow an installable app (the service worker) on a secure context: https://, or http://127.0.0.1. So from any other device the app must sit behind TLS. The documented path is Tailscale (tailscale serve), which gives you a real certificate and a private network in one step; any reverse proxy with a certificate works the same way. Over plain http:// on a LAN address, the app loads, but it cannot record and cannot be installed. It tells you so on screen rather than failing silently.
  • Generates a VAPID keypair on first start into the data directory (mode 0600). The private half never leaves the machine; the public half is served unauthenticated at /api/push/public-key, which is what a public key is for.
  • Runs no outbound calls except these four, and every one of them is yours to switch off: the model, Google (only if you connect it), the push service of a browser you subscribed from, and Open Food Facts. Fonts and every asset are served from the app itself.
  • The Open Food Facts lookup fires only when you scan a product barcode. It sends that barcode and nothing else: no account, no identifier, no other field. It goes from the server rather than from your phone, so Open Food Facts never sees your address or your browser. Two switches stop it, and they are not the same switch. The one in Settings, under Capture, is labelled "Look up scanned products" and it is yours: turning it off there stops the call from the next scan onward. food.off_lookup: false in config.yaml is the operator's, and it stops the call for good: the switch in Settings then renders disabled and says so, because a control that accepts a press and changes nothing is worse than no control. Either way, scanning asks you what the product is instead, which is the same question the app asks about any food it does not know.

What the token protects, and what it does not

The token gates every /api route except four things that cannot carry it:

Unauthenticated Why What it reveals
GET / and the app's own files a browser that has no token yet must be able to load the page that asks for it the application shell, the same bytes as this repository's build
GET /api/health systemd, uptime probes and the deploy smoke test liveness and component status; when a token is configured and the request does not carry it, the identity is blanked (no display name, no model endpoint or model names, no Google account, no diagnostic detail, no first-run flags, no hardware and timing fields). The timezone, currency and public URL stay, because the page needs them before it can ask for a token
GET /api/push/public-key the browser needs it to subscribe a public key
GET /api/gcal/callback Google's redirect lands in a plain browser tab nothing; it is authenticated by the single-use state it was issued with

The WebSocket (/api/ws) authenticates with the token as its first message, never in the URL, because URLs are written to access logs.

The token is a shared secret, compared in constant time, stored in your browser's local storage. It is not per-user, not rotated automatically, not rate-limited, and not a substitute for the network boundary. If someone is on the network segment the app listens on and has the token, they are you. Rotating it is one overlay edit and a restart; every browser then asks for the new value.

What you are trusting the model with

Every note, spoken or typed, is sent to the model endpoint with the extraction prompt, your timezone, your currency, your budget categories and the titles of your open tasks. Wherever that endpoint is, your notes go. A local llama-server keeps them on your machine; a hosted API sends them to that provider under that provider's terms. LifeOS does not know or care which; you do.

The model's output is not trusted. It is treated as a proposal:

  • The response must match a JSON schema (enforced by the server when it can, validated in Python always). Unknown action types cannot exist; unknown fields are dropped and the drop is recorded on the action.
  • Per-type validation runs on every payload: dates must parse, recurrence rules must be valid, categories must come from your own vocabulary, required fields must be present. Anything that fails downgrades to a confirmation card with the reason attached. It is never written.
  • Actions the model marks confident are executed at once. That is the product. But every execution is reversible from the Inbox, nothing is ever hard-deleted, and the card shows what was understood, quoting the transcript it came from.
  • Free text the model produces (titles, notes, journal entries) is stored as text and rendered as text. Journal markdown is sanitised before it reaches the page.

What the validation does not bound: the meaning of a well-formed action. A note that says "log fifty dollars on groceries" and a note that was crafted to make the model log fifty dollars on groceries produce the same row. Prompt injection through a dictated note is real and the defence is the same as for a mishearing: the card, the quote, and Undo. Do not feed the app text you would not want acted on.

What leaves the machine when you connect Google Calendar

Only what you authorise. LifeOS asks for the narrowest scope Google offers (calendar.app.created): it can create one calendar and write events into it, and it cannot see, list or touch any other calendar in the account, not even to find its own calendar again if it loses the id. The OAuth client secret lives in config.local.yaml; the refresh token lives in the database under a key the settings API cannot read or write. Disconnecting drops the token. Never put the client secret in the committed config.yaml.

Google, and Mozilla's and Google's push services if you enable Web Push, see your server's public key and a contact address you configure (push.contact). The push services also receive an encrypted payload they cannot read. That payload carries the reminder text.

Operator hygiene

  • config.local.yaml holds the token and the Google client secret. Keep it mode 0600. It is gitignored; keep it that way.
  • The database holds everything you ever said to the app. Back it up like a diary, not like a cache. The supplied unit file sets UMask=0077 so new files are private to the service user.
  • The access log is the server's journal. It records request paths and client addresses, never tokens (see above) and never note content.
  • Dependencies are audited at release with pip-audit and npm audit; the result for each release is in the release notes.

Reporting

If you find a defect with a security consequence, open an issue with [security] in the title, or email the maintainer from the address on the repository profile. Say what you can reach that you should not be able to; a request line and a response body are the whole report. There is no bounty; there is a fix and a thank-you in the notes.

There aren't any published security advisories