Security Model
Polylogue is a local-first archive. The daemon API is designed for loopback access by the CLI, TUI, web reader, and browser extension.
Threat Model
The primary threat is a malicious web page loaded in a local browser making requests to the daemon API. Browsers allow localhost requests from any origin by default, so any page the user visits can reach loopback ports.
Trust Boundaries
| Boundary | Inside | Outside |
|---|---|---|
| Loopback network trust | Same-machine processes (CLI, hooks, daemon) | Browser tabs the user happens to open |
| Browser-origin trust | The same-origin web shell served by the daemon itself | Every other tab, extension, iframe |
| Remote bind trust | Operator-acknowledged via --insecure-allow-remote + token |
Default: refused at startup |
| Process trust | The user's own processes (same UID) | Other users on a multi-user box |
Actors
- Same-user local process — CLI, hooks, scripts. Trusted: can read the SQLite archive directly anyway.
- Hostile browser origin — A page loaded in any browser tab the user has open. Untrusted: must be blocked from POSTing to mutating endpoints regardless of how it reaches the daemon.
- Browser extension — The Polylogue browser-capture extension uses a separate receiver with its own token (see
polylogue/browser_capture/server.py). Aligned by design — different threat surface. - LAN neighbor — Reachable only on
--insecure-allow-remotewith token. Default: refused at startup.
Assets
- Raw archive data (session content, raw artifacts, blob store)
- Local filesystem paths surfaced via
/api/sources - Daemon control operations (
/api/reset,/api/ingest,/api/maintenance/*)
Attack Surface
| Endpoint | Method | Risk | Auth | Origin |
|---|---|---|---|---|
/api/web-auth/session |
POST/DELETE | Mint/rotate or revoke a browser credential | Exact-origin bootstrap; current web credential for revoke | Exact origin |
/api/status |
GET | Read-only metadata | Bearer or web credential (when configured) | Credential-bound |
/api/health, /api/health/check |
GET | Health probe | Bearer or web credential (when configured) | Credential-bound |
/api/sessions, /api/sessions/:id, /.../messages, /.../raw |
GET | Read session data | Bearer or web credential | Credential-bound |
/api/facets |
GET | Read-only aggregations | Bearer or web credential | Credential-bound |
/api/sources |
GET | Returns absolute filesystem paths to authenticated callers | Bearer or web credential | Credential-bound |
/api/raw_artifacts/:id |
GET | Returns raw session payload | Bearer or web credential | Credential-bound |
/api/reset |
POST | Destructive — resets archive state | Bearer only when auth is configured | Exact origin |
/api/ingest |
POST | Mutating — schedules ingestion | Bearer only when auth is configured | Exact origin |
/api/maintenance/plan, /api/maintenance/run |
POST | Mutating — runs maintenance backfills | Bearer only when auth is configured | Exact origin |
Mitigations
-
Separate machine and browser credentials. When
--api-auth-tokenis configured, machine clients presentAuthorization: Bearer <token>. The first-party shell instead rotates a short-livedread/user_state/eventscredential throughPOST /api/web-auth/session. It can update marks, annotations, saved views, recall packs, and workspaces, but archive reset, ingest, and maintenance operations remain machine-bearer capabilities. The opaque value is stored in anHttpOnly; SameSite=Strict; Path=/cookie; the daemon keeps only its SHA-256 digest. It is never returned in JSON or accepted in a URL. Credential-shaped query parameters are rejected with400 credential_in_query; route metadata and disconnect logs omit query strings entirely, including values under unrecognized parameter names. Missing, invalid, expired, revoked, wrong-origin, and insufficient-scope decisions use explicit response codes. The lifecycle lives inpolylogue/daemon/web_auth.py; fetch and EventSource consume the same cookie. The digest registry prunes on issue/validate/revoke and enforces hard global and per-origin record bounds, so rotation cannot create unbounded retention. -
Loopback-only by default. The daemon binds to
127.0.0.1by default. The "loopback" predicate is the sharedpolylogue/core/loopback.py:is_loopback_host(RFC 5735: full127.0.0.0/8,::1, andlocalhost). Non-loopback bind (--api-host 0.0.0.0etc.) requires both--insecure-allow-remote(operator opts into the risk) and--api-auth-token; the enforcement lives inpolylogue/daemon/cli.pyand rejects either missing flag at startup. -
Exact origin for browser credentials and mutations. A loopback origin on a different port is still a different origin. Bootstrap therefore requires the browser
Originauthority to matchHostexactly, credentials are bound to that canonical origin, and mutation requests repeat the exact authority check. MissingOriginremains valid for trusted non-browser bearer clients. The checks live inpolylogue/daemon/web_auth.pyandpolylogue/daemon/http.py:_check_cross_origin. -
No CORS preflight.
OPTIONSrequests return405 Method Not Allowedby design. The daemon does not advertiseAccess-Control-Allow-*headers. Cross-origin browsers cannot meaningfully use the API even on read endpoints because the response lacks the CORS headers browsers require.
Explicit Decisions
These decisions are documented here so a regression that quietly flips one of them is recognized as a security policy change rather than an implementation tweak.
Raw artifacts are not content-redacted
/api/raw_artifacts/:id returns the full raw payload (JSONL or other
provider artifact). The bearer token implies operator-level trust:
the same operator can read the blob store directly. Redacting at the
API surface would create a false sense of isolation while leaving the
filesystem unguarded.
/api/sources returns absolute paths
/api/sources returns absolute filesystem paths in the root field
to authenticated callers. The operator needs them for source diagnosis
(why didn't this directory ingest?). Same trust argument as raw
artifacts: same-user filesystem access already exposes these paths.
OPTIONS returns 405
No CORS preflight is offered. The web shell and browser extension are same-origin or use a dedicated receiver, both of which avoid the preflight path. Adding CORS to the daemon API would be a deliberate architectural change.
Browser capture has its own token
The browser-capture receiver (polylogue/browser_capture/server.py)
is intentionally a separate component with its own
--browser-capture-auth-token, --browser-capture-origin, and
--browser-capture-allow-remote flags. The daemon HTTP API and the
browser-capture receiver have different trust models — extension
talking to receiver vs. CLI/hooks/web-shell talking to daemon — and
sharing a single token would conflate them.
Unlike the daemon HTTP API, the receiver requires a bearer token by
default even on plain loopback: without an explicit
--browser-capture-auth-token, one is auto-minted into a 0600 file
(polylogued browser-capture token show prints it for pairing) so that
no local process other than the paired extension can read receiver
state or post captures. --browser-capture-allow-no-auth is the
explicit, logged opt-out (polylogue-gnie).
Non-Threats
Out of scope:
- Multi-user access. Polylogue is single-user; no user isolation.
- TLS / HTTPS. Loopback-only by default; remote bind is operator opt-in and out of scope for built-in TLS.
- Encryption at rest. SQLite is plaintext; disk encryption is the OS's responsibility.
- CSRF tokens (in addition to Origin check). The Origin allowlist is the CSRF boundary; we don't issue per-request CSRF tokens.
- User accounts / RBAC / multi-user auth. Single-user model.
- Token-leak prevention beyond our own logs. We do not log the token; we do not put it in URLs. Operators that paste it into shell history, ticket trackers, or screenshots own that risk.
See Also
docs/daemon-threat-model.md— finer-grained asset/threat tables.polylogue/daemon/http.py— auth and origin-check implementation.polylogue/daemon/cli.py:309-319— remote-bind enforcement.polylogue/browser_capture/server.py— separate receiver auth.tests/unit/daemon/test_daemon_http_security.py— per-endpoint security matrix.tests/unit/daemon/test_daemon_cli_remote_bind.py— remote-bind refusal coverage.