Security
Flint is a personal tool for your own machine. It runs model-proposed shell commands as the user it runs as, and anyone who can reach the page can sign up, so keep it on localhost (the default, and what Docker Compose publishes). Accounts exist to keep your own conversations and settings organized, not to make it safe to share with other people.
Accounts and sessions#
- Passwords and security-question answers are hashed with bcrypt and never returned.
- The Brave Search key is stored as entered (Flint has to send it to
Brave), but it's never sent back to the browser: not in the Settings
page, not in
/api/me, not in any API response. Settings shows only its last four characters, with Replace and Remove. - Sessions are random ids stored in SQLite and sent as an
HttpOnly,SameSite=Laxcookie. They last 30 days. Logging out deletes the session, and a password reset deletes all of that user's sessions. Changing your password in Settings needs the current one and logs out every other session, keeping the one you made the change from. - Changing your email needs your current password: the email is the login, so a stolen session alone can't change it and lock you out.
- Every conversation, message, attachment, command and memory is scoped to its owner. Another account's resources return 404, not 403, so their existence isn't revealed.
- Recovery lookups answer the same way whether or not the email exists.
- Deleting an account (
DELETE /api/me) needs the password, plus every security answer when questions are set, so a stolen session alone can't erase an account. A wrong password or answer gets the same message. It deletes the user row, which cascades to every session, conversation, message, command, summary, memory and security question, then removes the attachment files from disk.
Rate limits#
An in-memory sliding window per client IP (r.RemoteAddr):
| Endpoints | Limit |
|---|---|
| login | 5 per 5 minutes |
| signup | 5 per 5 minutes, separate from login |
| recovery questions + reset (shared) | 5 per 30 minutes |
| account deletion | 5 per 30 minutes |
| profile + password change (shared) | 10 per 15 minutes |
X-Forwarded-For is ignored on purpose. Without a trusted-proxy list,
anyone could set it to get around the limit. Behind a reverse proxy, every
client therefore shares the proxy's IP and its limit.
File tools#
- Reads run without approval with a local model (
read_file), a deliberate choice: nothing executes, so the risk is only what the model gets to see, and that is confined to the attached folder. With a cloud model, a read waits for your approval like a command, since what it reads is sent to the model's host. Every path is resolved through symlinks before the check, so.., absolute paths and a link pointing outside are all refused. Binary files and files over 256 KB aren't read. - The shield applies to them too. The resolved path is checked
against the same secret-file rule as shell commands (SSH private keys,
/etc/shadow), so attaching your home folder doesn't let the model read~/.ssh/id_ed25519, through a symlink or otherwise, and none of the three tools can write there. - Writes and edits always need approval, shown as a diff of exactly what will be written. The new content is computed in memory when the edit is proposed and written only on approve, to a temporary file renamed into place, keeping the file's permissions. The same folder check applies, and a new file's parent folder must already exist.
- A stale edit is refused. The file's hash is stored with the proposal; if the file changed before you approve, nothing is written and the model is told to read it again. An edit you corrected in the card gets the same check: your lines replace only the region the model proposed to change, and the rest of the file must still be as it was. So does a partial accept: only the changes you checked are applied, onto the file as it was proposed against.
- A cloud model's edits are staged, never written unreviewed. They're kept in the database until you decide the review after its reply; its reads in the same turn see them, the disk doesn't. Each file is hash-checked against the disk as it was when its first edit was staged, so a file you changed meanwhile is refused, not overwritten. While edits are staged, a shell command the model proposes isn't run (it would act on the old files), and a new message or a folder change waits until the review is decided.
- Unlike the shell's working directory, this is a real boundary for
these three tools.
run_shellcan still reach anything the server's user can, which is why it stays approve-every-command.
Shell commands#
Layers, in order:
- Human approval. Every shell command is held as pending, and its exact text is shown unchanged. Nothing runs without an explicit approve. This is the real safety net.
- Shield (
shield.go). Blocked when proposed and again when run:rm -rfaimed at/,~, or the whole current directory (.,*), fork bombs,sudo, pipingcurl/wgetinto a shell,mkfs,ddto/dev/*, and reading/etc/shadowor SSH private keys (any~/.ssh/id_*; before 0.3.0 the rule missed names with digits, such asid_ed25519). The model is told to ask you to run a genuinely needed blocked command yourself, not to work around the block. This is a floor, not a guarantee: a Turing-complete shell can evade any pattern list. - Preconditions (
preconditions.go). The program must exist, and a simple read (cat,head,tail, ...) must target a path that exists. Compound commands skip the path check. A failure goes straight back to the model, without asking you to approve something that can't work. - Serialization. Every request touching a conversation takes that conversation's lock, and approve/deny re-reads the command's status inside the lock. A double-clicked approve can't run a command twice.
The working directory is the attached folder, but a command can still
cd elsewhere or use absolute paths. Commands time out after 60 seconds,
and their output is capped at 20,000 characters.
Commands an @agent agent proposes go through the same four layers:
shown in the chat as "Agent N wants to run" with the exact text, held
until approved, checked by the shield when proposed and again when run,
and checked for preconditions. They are approved through their run
(POST /api/agent-runs/{id}/commands/{cmdId}/approve), never through the
chat's own approve route, which ignores them, and they never count as the
chat's pending command. Approving flips the command from pending in one
SQL statement that also checks the run belongs to the caller, so two
racing approvals, or an approval and Stop, can't both win. Stop cancels
every waiting command, and one approved in the same instant is cancelled
too, never left to run. Each agent may propose at most the "Commands per
agent" setting (8 by default), and stops after 5 failed or denied
commands in a row.
Network#
- Flint talks to Ollama, and to Brave Search only for an explicit
@webmessage using that user's own key. Nothing else leaves the machine, unless you chat with an Ollama cloud model (:cloud): Ollama then forwards the whole chat, attached folder files and command output included, to ollama.com. Flint doesn't block that, since you pulled the model yourself, but tags it "cloud" in the picker, Settings and the chat, and says so at the top of the chat. It's detected from theremote_hostOllama reports, not the name. Settings → Connection lists every search still in your chats, with the exact query sent. - The UI is same-origin with the API, so no CORS headers are set.
- Flint listens on
127.0.0.1by default (HOST), and Docker Compose keeps it there, because of open signup and the shell tool. - Model output is rendered as Markdown through DOMPurify, restricted to
HTML and MathML (no SVG). Rendering must never fetch from the network:
remote markdown images become links,
<img>keeps only local ordata:sources, and media tags plusstyle,srcset,posterandbackgroundare stripped, since any of them could load a remote URL (for example, a tracking pixel in a reply). - Web search sources are shown as links only if they're
http(s), so a result can't smuggle in ajavascript:link.
Memory#
A saved memory is sent back to the model in later chats, so a wrong one
keeps misleading it. That's why a model-drafted memory is only saved after
the user has read and approved it, and why memories are only written or
searched on an explicit @memory command.
The Settings list names the chat a memory came from by joining on conversations owned by the same user, so it can never show the title of another account's chat.
Agents#
- What an agent can read. Only direct files of the attached folder
that the plan names (anything else, such as
../xor/etc/passwd, is dropped in code, both from the model's plan and from edits on the plan card), the web results for its one query, and the output of commands the user approved. Files are read in Go, never by the model. - The web. The planner never proposes a search, and any web query in its output is dropped. A search runs only if the user added it on the plan card, needs the account's Brave key, and is never combined with files in one subtask, so a local fact isn't sent to Brave.
- Isolation. Agent runs, agents and transcripts are reached only through conversations the caller owns; another account gets 404. Transcripts are shown to their owner and never sent back to the model.
- Made-up answers. An agent that was given no files or web results and ran no command that worked has its answer replaced with "nothing to answer from", whatever it wrote, since it can't have read anything.
Reporting a vulnerability#
Please report security problems privately through the repository's Security → Report a vulnerability on GitHub, not in a public issue. That matters especially for anything that gets around the shell-command safety layers.