serve protocol and threat model
This is the contract between the embedded Svelte app (web/) and the Go
server (internal/serve/). The frontend’s web/src/lib/api.ts is the visual
reference; this document is normative where they differ.
Endpoints
All /api/* responses are Cache-Control: no-store. POST/PUT bodies are
capped at 4 MiB and must be application/json (415 otherwise).
| Route | Auth | Notes |
|---|---|---|
GET /, /assets/* |
public | index.html is no-cache; hashed assets immutable. |
GET /api/meta |
public (minimal) | Always: {version, mode, tls, authKind, authenticated}. Authed adds {roots, styles, defaultBrowse} (~/Projects when jailed, else first root) and {features: {clone}} (true only with --allow-clone and git installed); the UI hides clone entry points when false. |
POST /api/login, POST /api/logout |
public / session | {password} or {token}. 204, 401, or 429 with retryAfter. |
GET /api/recents, POST /api/recents, DELETE /api/recents?root= |
session | POST records an open (GETs never mutate). Cap 25. |
GET /api/browse?path= |
session | Directories only, 5000 entries, isGitRepo by lstat(.git). Dot-directories hidden unless &hidden=1. Entries carry modTime (unix millis) for sort-by-updated. |
GET /api/tree?root= |
session | {path, size, tokens, language, score} with TUI-identical token/score logic. Plus budget + budgetSource (toml/flag/default): the resolved token budget the client displays and echoes back (see Budget below). |
GET /api/file?root=&path=&mode=full|sigs |
session | Redacted. 1 MiB preview cap with truncated. Plus spans: [line, start, end, kind] tuples (byte offsets per line) from the shared internal/highlight engine — the same spans the TUI preview renders. Kinds: keyword string stringescape regex comment doccomment shebang number bool null type builtin constant tag function decorator markupheading variable property attribute tagattribute markuplink; anything else renders plain. Spans are parsed on the exact redacted bytes sent, so offsets always line up; absent for unsupported languages. |
POST /api/smart-select |
session | {root, budget} → {selections: {path: full|sigs|skip}}. |
POST /api/pack |
session | {root, selections, budget, style, prompt, redact} → {document, tokens, fileCount, redactions, skipped, sections}. At most 2 concurrent packs (503 otherwise. sections is always an array of {path, tokens, start, end} byte ranges into document (XML/Markdown/Plain) for outline navigation; empty when no files are kept. |
GET/PUT /api/settings |
session | {defaultStyle, defaultBudget, theme, showHidden, fileSort}; theme validated [a-z0-9-]{1,32} (system follows the OS), fileSort is name or updated. The theme id is shared with the TUI (mirrored to the legacy field), so changing it in either frontend changes it in both. defaultBudget seeds fresh workspaces (see Budget). |
POST /api/session/launch |
public (token is the credential) | {token} → {app}. --app auto-login: one-time, 60s TTL, loopback-only. Shares the login lockout. Any failure is the same generic 401. |
GET /api/app/heartbeat |
session | SSE liveness stream, app mode only (404 otherwise). No server-side ticker; the handler blocks until the client leaves or the server starts shutting down. |
POST /api/clone |
session | {url, branch?, depth?} → NDJSON progress lines ({"type":"progress","phase","percent"}), then one done ({root, name, url}) or error ({message, detail?}). 404 without --allow-clone (or without git). Rejects local paths, file://, ext::, leading -, URLs with embedded passwords, and depths outside 1–1000. One clone at a time, 5-minute timeout; aborting the request cancels git and removes the checkout. |
GET /api/clones |
session | This session’s temporary clones, newest first ({root, name, url, branch?, started}). Same 404 gate. |
DELETE /api/clone?root= |
session | Removes one server-created clone (directory deleted, jail root withdrawn). Anything else is 404 and untouched. Same 404 gate. |
Budget
One chain, server and client agree: explicit request budget
(pack/smart-select body) > serve --budget flag > project .sift.toml
([sift] budget) > global config file > persisted defaultBudget
(preferences) > builtin 64000. The .sift.toml always beats the persisted
default so per-project files stay authoritative; the web client still
overrides per session via the TopBar presets (badged .sift.toml when the
file won) and persists preset picks back to defaultBudget only when no
.sift.toml/flag budget is set. A literal 0 request budget keeps endpoint
semantics (pack: no trim; smart-select: config budget).
Failures use generic messages and status codes: 403 never reveals whether a path exists; denied project opens log only the last two path segments.
Auth
- Local token: 256-bit
crypto/rand, base64url, printed as a URL fragment. Submitted once viaPOST /api/login, then cleared. - Password: compared as
HMAC-SHA256(key, candidate)vsHMAC-SHA256(key, password)withsubtle.ConstantTimeCompare. Never persisted; restart-safe because there is nothing to persist. - Session cookie:
v1.<expiry>.<app>.<nonce>.<HMAC>, per-process key, 12h lifetime, re-issued under half-life.appis1for a session created by a--applaunch token, so app mode survives reloads without server state.HttpOnly; SameSite=Strict; Path=/,Secureon TLS (or trusted-proxy https). - Throttling: exponential per-IP backoff (first failure answers 401, the
next waits) plus a global rate cap; 429 carries
retryAfter. Pruned lazily. - Public set: static assets,
GET /api/meta(minimal),POST /api/login. Everything else 401s without a session (asserted by enumeration test).
Network hygiene
- Host allowlist on every route (static included): bound host, loopback
names,
--allowed-host. Else 421 (DNS-rebinding defense). - Origin/fetch checks on every non-GET:
Originhost must equalHost;Sec-Fetch-Site: cross-siterejected.OPTIONSis 405. - No CORS, ever. No
Access-Control-*on any response (asserted). - Headers everywhere: strict CSP (
script-src 'self';style-src 'self'plus onesha256hash for the inline first-paint<style>inweb/index.html, pinned byTestFirstPaintStyleHash;style-src-elemrepeats both, andstyle-src-attr 'unsafe-inline'allows Svelte’s runtime style bindings, which cannot execute script),nosniff,no-referrer,DENYframing,same-originCOOP/CORP, HSTS on TLS. - Limits: 5s header / 30s read timeouts, 16 KiB headers, request-context cancellation into scan and render.
- Re-attach (loopback only): on a bind conflict with
--open/--app, the new process probesGET /api/meta(public version facts, no credentials) and, only for a matching sift shape, opens the plain base URL. It never learns the existing token, mints no launch token, and arms no shutdown.
Filesystem jail
One resolver (internal/serve/jail) serves every endpoint: absolute path →
Clean → EvalSymlinks → separator-boundary containment against
startup-resolved roots → denylist. Failures are a generic 403.
- Denylist (any depth):
.ssh,.gnupg,.aws,.kube,.config/gcloud,.password-store,.local/share/keyrings,.docker/config.json,.netrc. - Symlink-to-file inside the root reads normally; escaping links are skipped (walker) or 403 (API). Only regular files are ever opened.
- Project
.sift.tomlfiles are screened byconfig.CheckProjectConfig:prompt_file/output/ui_theme_fileescapes refuse the project. - Every request resolves its engine config like the CLI inside the target
directory (defaults → global file → that project’s
.sift.toml); the server’s startup directory never leaks into other projects. Explicit serve flags overlay the project file, exactly like CLI flag precedence. Output sinks stay disabled. - The scan itself is read-only: hardened git (
GIT_OPTIONAL_LOCKS=0, no fsmonitor/hooks/drivers, scrubbed env) plus the tree-hash proof test.
Clone
Cloning is the one deliberate exception to read-only: the server runs
git clone on behalf of the session (shared internal/gitclone package with
the sift clone CLI, same validation).
- URLs: remote only (
https,http,ssh,git, scp-likeuser@host:path). Local paths andfile://are refused in both the CLI and the server;ext::<helper>remotes, leading--arguments, and URLs with embeddeduser:password@are refused too. The URL always follows--. - Git environment:
GIT_ALLOW_PROTOCOL=http:https:ssh:git,GIT_TERMINAL_PROMPT=0, a detached session (no terminal prompts), plus the scanner’s hardened git flags. Auth is whatever the system git already does (credential helpers,~/.gitconfig, ssh agent); nothing credential-shaped crosses the browser. Private repos that need a login fail fast with git’s own message. - Checkouts: private
sift-clone-*temp dirs holding one repo dir each, deleted on every shutdown path (signal, idle timeout, window closed) and by the OS temp cleaner after a crash. At most 5 per session, one clone at a time, 5-minute timeout, no submodules,--depth1–1000 (default 1). - Jail: each finished checkout is registered as an extra jail root scoped
to exactly that directory (siblings and the temp parent stay 403); removal
withdraws it. The startup roots shown in the UI never change, and the
denylist applies beneath clone roots too. A cloned repo’s own
.sift.tomlis screened at clone time and refused with its checkout removed. - Not recents: clones are listed per session (
GET /api/clones) and open like projects, but never enter the on-disk recents or the resume target — both would dangle after the server stops. - Availability: strictly opt-in via
--allow-cloneon every bind (plus system git). Without the flag the routes are 404 andmeta.features.cloneis false. Clone URLs are logged credential-stripped.
Content safety
- Redaction defaults on for previews and output; token counts are
post-redaction. Remote
redact=falseis 400 without--allow-unredacted. - File content is text nodes only; downloads use fixed MIME types and sanitized filenames.
- Logs (
log/slogto stderr) carry logins, refusals (trimmed paths), and startup facts — never tokens, passwords, cookies, or file contents.
Idle design
No tickers, janitors, watchers, caches, or SSE. Grammars/redaction rules load
on first use. The only idle goroutines are the accept loop and per-connection
readers (asserted by the goroutine-baseline test). --idle-timeout exits the
process after the quiet period via a one-shot timer reset per request; a clone
in flight postpones the exit until it finishes (its request began long ago),
and a long clone resets the timer when it ends. Frontend fetches on user action only (plus the login lockout countdown).