Everything is local: SQLite for structured data, no telemetry. Nothing leaves your machine except calls to the external APIs you configure.
| File | Contains |
|---|---|
config/config.yaml |
Where the server binds and where the database lives — no secrets |
data/recommendations.db |
Consumption history, encrypted credentials |
data/.credential_key |
Fernet key for those credentials |
data/chroma_db/ |
Vector embeddings of your content, left by a pre-AI-removal release |
Never commit these files to version control.
OAuth tokens and API keys are encrypted with Fernet in the credentials table.
Nothing else is. Titles, ratings, reviews and completion history sit in the
database as plaintext.
- The key lives at
data/.credential_key, or whereverRECOMMENDINATOR_KEY_PATHpoints, created0600inside a0700directory. A group or world readable key file raisesPermissionErrorrather than decrypting anything. config.yamlholds no secret. Every credential is entered through the UI orsource set-secret/settings set-secretand lands in the encrypted table.- A status read's
connectedreports the stored credential row, not the resolved config, so no control it offers answers 404. - A connect route refuses an id whose plugin is not its own: the id is the credential key, so an unchecked one files a GOG token where Trakt reads its own.
- An upgrade does not move tokens an earlier release stored under the plugin's name: several sources can share a plugin, and nothing records which owns the token. Reconnect the source to store one where it reads it.
- No endpoint returns a credential value.
Pointing a credential_bound field at a different host is refused — url
on Sonarr, Radarr and Calibre-Web. Host and port decide, so the same endpoint
switching between http and https goes through untouched, a downgrade
included: the credential then crosses the network in cleartext.
To move a source, clear its secret (source clear-secret or the Data tab),
save the new URL, then enter the new host's credential.
The same binding holds at every hop, not just the first request: a redirect is
followed only while Location stays on the origin the request started from, a
configured url or a service's own API host. Every credentialed request walks
it: enrichment providers, OAuth token exchanges, the cover fetch carrying a
library's basic auth.
A source url must be http or https, must name a host, and must not embed
user:password@. Source config is validated at write and again at
sync, neither 400 carrying the plugin's own message: a write names the field
it blames, or repeats a path-containment refusal verbatim, and a sync names the
settings it matched or answers a fixed string. The reason goes to the log
instead.
Two kinds of error are echoed anyway. Something that would not load is the first. A plugin module that would not import, a directory holding no templates, a file the chosen format cannot parse. That message describes this install, or the caller's own input that could not be read, and nothing else the request reached — it is the answer needed to fix either. Every route carrying one is behind a session.
The second is a refusal whose wording is fixed. Its message is composed from values the request already sent and from names the app's own registries hold, so echoing it hands the caller nothing it did not arrive with.
Everything else is logged and answered with a fixed string, including a row that
fails to save inside a 200, which is named by exception class and never quoted.
detail=str(error) on anything outside those two kinds is a defect. To see
where it stands today, run git grep -n 'detail=str(' -- src.
One account, username and password, and a session cookie. A fresh instance
has none: the first visitor to the web UI names the account and sets its
password, and is signed in by that request. Change it later from Settings →
Account, or with uv run python -m src.cli account set-password on the machine
holding the database — there is no email and no reset link.
- The claim window stays open until someone uses it — boot warns while it is, and the loopback default bounds who can reach it.
- Nothing under
/apiis exempt but the/api/authroutes.GET /api/statusstays gated: its feature report is a free fingerprint, and the container health check reads that 401 as healthy. - The SPA shell (
/,/static/*) is not gated: it draws the sign-in form, and carries the app's assets rather than your data. - The cookie is
HttpOnly,SameSite=Strictand good for 30 idle days, rolling forward on use. NoSecureflag — this app serves no TLS, so a Secure cookie would never be sent at all. Beyond loopback, put a reverse proxy in front. - A password is at least 12 characters, wherever it is set: the setup
screen, Settings → Account and
account set-password. - Passwords are scrypt digests under a per-account salt, session tokens SHA-256 digests. Sessions are revoked server-side, so signing out or changing the password really ends them.
Enter secrets in the app, never in config.yaml:
- Source secrets (Steam, Sonarr, Radarr, and the rest): the web Data tab
or
uv run python -m src.cli source set-secret <source> <key>. - Global provider secrets: the web Settings page or
uv run python -m src.cli settings set-secret <key>.
A Steam key comes from https://steamcommunity.com/dev/apikey and grants read
access to your Steam library. Rotate it by re-running source set-secret.
| Service | Purpose | When |
|---|---|---|
| Steam, GOG, Epic Games | Game library sync | That source enabled |
| Sonarr, Radarr | Media library sync | Configured |
| TMDB, OpenLibrary, Hardcover, RAWG, Wikidata | Metadata enrichment | Enrichment and that provider enabled |
IGDB, plus id.twitch.tv, which the client secret is sent to for an app token |
Metadata enrichment | Enrichment and IGDB enabled |
web.allowed_origins cannot authenticate a cross-origin client. The session
cookie is SameSite=Strict, so a browser never attaches it to a request from
another origin, whatever CORS allows. What the setting still reaches is the
ungated surface: GET / and /static/*, the SPA shell. It defaults to
http://localhost:18473 and applies on restart.
The web interface binds 127.0.0.1 by default, and Docker publishes its port on
127.0.0.1 too (APP_BIND_PREFIX). Reaching it from another machine means a
reverse proxy terminating TLS, or accepting that your password and session
cookie cross your network in cleartext — the app never serves TLS itself. Do not
expose it to the public internet without the proxy. Under Docker, services talk
over an internal network isolated from the host by default.
Source config is writable over the API, so a plugin whose config names a
filesystem path (roms, and private scanners) refuses one resolving outside
security.allowed_source_roots; validate_config and fetch both check. An
import is outside it because no importer opens a path at all. The upload itself
is Starlette's: a part over 1 MiB spools into the system temp directory,
unnamed and unlinked when the request's form closes.
# config/config.yaml — defaults to ["inputs"] when absent.
security:
allowed_source_roots:
- "inputs"
- "/srv/roms"The list is read from config.yaml and is not a settings-registry leaf, so
PUT /api/settings cannot widen it and point a source at /home; adding a root
is a config.yaml edit the watcher picks up without a restart. Both sides are
resolved before comparison, so a symlink under an allowed root cannot escape it,
and an empty list allows nothing.
Imported CSV and JSON are parsed with standard libraries, and invalid rows are skipped rather than executed. Custom rules are stored as typed and collapsed to a single line before the interpreter reads them. See CUSTOM_RULES.md. Neither path executes anything from user data.
SQLite has no authentication, so file permissions are the control:
chmod 600 data/recommendations.db
cp data/recommendations.db data/recommendations.db.backup
gpg -c data/recommendations.db.backup # if the backup leaves the machineOnly a copy of the file preserves provenance. An export carries neither the field-write ledger nor any other child table, so a library restored from one keeps no record of who stated what: every writer's word is gone, every pin, hold and choice with it, and the values that survive are claimed by the import alone.
uv pip list --outdated
uv sync --locked-
config/config.yamlis git-ignored and0600 - The web account is claimed, with a password only you know
- API keys are not in code
-
data/logs/is treated as sensitive and not shipped anywhere - Database file has restricted permissions
- Web interface on localhost (Docker's default), or behind a TLS proxy
- Docker containers run as a non-root user
The logs are not guaranteed key-free. Every integration that puts a credential in the request URL renders a request failure as its status code or error class, and every OAuth connect flow logs only an error type name or a status code. None of them attaches a traceback.
Rendering the message is only half of it: a traceback walks __cause__, so an
exception chained from a request error prints that request's URL.
tests/test_credential_url_chains.py holds every such caller to both halves, a
chain-free handler and an entry in its _CREDENTIAL_URL_FUNCTIONS list, and
enrols new ones by scanning src/ and private/plugins/ for a credential key
beside a params= call.
That covers how a failure is rendered. The transports carry the same URLs.
urllib3.connectionpool logs each request target, query string included, at
DEBUG — and at WARNING on its retry path. The shared wiring holds httpx,
httpcore and urllib3 at WARNING, which closes the DEBUG half alone; the
retry line never runs because requests' default adapter builds Retry(0).
Mounting an adapter with retries would leak keys at any level.
A refused config write is redacted before logging, but the match is exact, so a truncated or encoded form of the secret survives it.
A committed agent is a prompt carrying the reviewer's permissions. Treat a
change to anything under .claude/agents/ like a change to CI configuration:
an edit changes what the review does, including the review of the branch making
the edit. Reviewing that diff by hand is the only control.
Changes are audited for the following before they are committed.
- Credential exposure: hardcoded secrets,
config/config.yamlreferences, secrets in logs or error messages - Injection: SQL, command (
shell=True), path traversal, template - Network and API: CORS, missing TLS validation, SSRF, exposed internal errors
- Python pitfalls:
assertfor validation (stripped under-O), shell execution through theosmodule, mutable default arguments - Data handling: unsafe deserialization, race conditions, shared state mutation
- Dependencies: known vulnerabilities, unpinned versions, needless packages
- Type safety: Pyright diagnostics for
Anyhiding unsafe casts, and missing return types on endpoints
config/config.yamlmust never be referenced in code or tests- CORS defaults to localhost, never wildcard
allow_credentials=Falsewhen wildcard origins are used- Internal error detail never reaches an HTTP response (
detail=str(error)is forbidden), with the two kinds above as the only exceptions. That grep is not the whole surface: a failed plugin import also reaches a body through named response fields on the source and plugin listings, and throughunusable_detailin the sync 400 and inrequire_plugin, which composes the failed module's own exception text - Module-level imports only
- Copy dicts and lists before mutating data passed in from outside
is not Nonerather than a truthy check for security-relevant values
SessionStart hooks are deliberately kept out of the tracked
.claude/settings.json, because tracked settings ship to every clone and hooks
run without a prompt. The risk is not unique to hooks: the agents' prompts tell
them to run the project's quality-check command, so reviewing a contributed
branch runs that branch's test code as you either way.
permissions.allow is ambient authority of the same shape, governed the same
way. The tracked list is bounded on execution alone: no tracked entry runs
a command of its own choosing, though a git-diffmine on PATH extends the
grant and no deny list reaches it. Anything with an --exec, --extcmd or
pager escape belongs in the gitignored local settings, where it is one person's
choice for one machine. git grep is excluded: --open-files-in-pager runs its
value through a shell.
Grants match by prefix, so some entries are denied rather than left out.
git difftool shares git diff's prefix and takes --extcmd=<command>, which
runs anything. The git diff-* plumbing family is denied alongside it, since no
reviewer invokes plumbing and the family keeps growing. A deny is the one kind
of rule a project may reasonably ship to every clone.
Reads and writes are not bounded at all. git diff --no-index prints any
two files on the machine by absolute path, unprompted. git-diff(1) describes
--output=<file>, which creates or truncates an arbitrary path, and git log
takes the same diff options.
No prefix rule can close --no-index or --output, because both are flags
and a flag may sit anywhere in the arguments. The grant stays because dropping
it means dozens of prompts in a single review round.
So the grant is unguarded here. What asks an agent not to use it that way is the agents' own prose.
enabledPlugins is code by definition and warrants the same care.
Security review is part of the pre-commit workflow in CONTRIBUTING.md and reads this file and CLAUDE.md for the rules above. Each finding carries severity, CWE classification, evidence, impact and remediation.
Do not open a public GitHub issue. Contact the maintainer privately, and allow reasonable time for a fix before disclosure.