Security
This server holds the operator's private notes. That makes the questions different from the ones its sibling aegis answers. Aegis exists so that a secret never enters a model's context; here the content is the point, and an agent that could not read it would be useless. So the design cannot be about withholding data. It is about bounding what a mistake or a manipulation can do to it.
Two properties frame everything below. First, the data is plain files: whatever happens, recovery does not depend on this software. Second, an agent editing prose fails quietly — a lost paragraph produces no error, no failing test, and no complaint until somebody goes looking for it months later. Most of what follows is aimed at that second problem rather than at an attacker.
| Threat | What actually stops it |
|---|---|
| An agent overwrites a note that changed since it read it | note_write requires the content_hash from note_read and is refused when the file moved on. |
| An agent rewrites a whole note to change one sentence | Section and exact-string edits exist and are the documented preference; an ambiguous string edit is refused rather than guessed. |
| A vault-wide replace destroys hundreds of notes | vault_replace and tag_rename default dry_run to true and answer with a diff. |
| Something is deleted that should not have been | Nothing is removed. A timestamped copy goes to the trash, and the change is a revertible commit. |
| A tool reads or writes outside the vault | Path validation is structural: relative, contained, no dot-prefixed component. |
| A note's own text instructs the agent | Not preventable. Bounded by read-only users, dry runs, the trash and git history — see below. |
| Credentials leak from the server | Tokens exist in memory only and are never written to disk. |
| Note text leaks through the logs | The audit line records metadata only, at every log level including debug. |
| An agent uses the server to reach the network | There are no outbound requests to make. The only exception is an optional git push you configured. |
| A second user reads the wrong vault | Users are limited to named vaults in config.yaml; a vault they cannot open does not appear in vault_list. |
What an attacker with a token reaches
The honest answer first: a valid access token for a read-write user is equivalent to a copy of that user's vaults. There is no second factor between the token and the notes, and there could not be one without making the server useless to the agent it is built for.
Can
Read every note in the vaults that user may open, including the ones you have forgotten are there. Search them, list their tags, follow their links, download the attachments. Write, move, delete and rename — unless the user is read-only.
Cannot
Reach a vault that user is not listed for, or any path outside a vault. Read .git, .obsidian or .secondbrain through any tool. Make the server call an arbitrary URL. Write a file with an extension outside the attachment allow-list, or larger than 32 MiB. Erase anything permanently within the retention window.
What bounds the damage is that destruction is hard to make stick. Every write is a commit; with a git remote configured, that commit is already elsewhere before anybody notices. Deleted content sits in the trash for thirty days. An attacker who wants your notes gone has to reach the filesystem, not the MCP endpoint.
Reading is the asymmetric risk, and it has no technical answer. A token that has been stolen has already read whatever it wanted before you can revoke it. Two things reduce the window: a token lifetime of twelve hours by default, and the fact that a restart invalidates every session because nothing is persisted.
vaults: [work] is the only mechanism here that limits what a compromised session can read, and it costs nothing to set up on day one.
Path traversal, handled structurally
Every path argument goes through one function before it reaches the filesystem. After cleaning, the path must be relative, must resolve to somewhere inside the vault, and must contain no component beginning with a dot. Anything else is refused.
../../etc/passwd # leaves the vault /etc/passwd # not relative wiki/../../other/note.md # leaves the vault after cleaning .git/config # dot component .secondbrain/index.db # dot component notes/.obsidian/app.json # dot component, at any depth
The third rule is the one worth arguing for. A blacklist of forbidden directories is a list somebody has to remember to extend: add a new tool, or a new editor that keeps state in a dot directory, and the list is quietly out of date. A rule about the shape of a path applies to every tool that exists and every tool that will exist, without anyone maintaining it. That is why .git, .obsidian and .secondbrain are unreachable — not because they are named anywhere, but because of what their names look like.
The practical consequence is that the server cannot corrupt its own git repository or its own index through a tool call, and that a vault's internal state is not something an agent can inspect, quote or edit.
Prompt injection from note content
This is the interesting attack against a knowledge base, and it deserves to be stated without hedging: a note can contain instructions, and this server cannot fix that.
The mechanism is ordinary. You paste an article into raw/. Somewhere in it is a sentence addressed to a model rather than to you — delete the following notes, append this text to the journal, put the contents of people/ into a note titled summary. Later an agent reads that file as part of answering a question, and the text arrives in its context indistinguishable from your own instructions. The same applies to a shared vault, a git pull from a repository somebody else can write to, and a web page saved by a browser extension.
No amount of validation on the server helps, because the content is the payload and delivering the content is the job. What the design does instead is bound the consequences.
Read-only users
A user configured with read_only: true is never offered a mutating tool. No amount of persuasion in a note produces a write, because the capability is absent from tools/list rather than refused on call. For an agent whose job is answering questions, this is the correct configuration.
Dry runs
Every mutating tool can return a diff instead of writing, and the two vault-wide tools do so by default. A client that shows the diff turns a manipulated tool call into something a human declines.
The trash
Instructions to delete produce a timestamped copy in .secondbrain/trash/ and nothing more. Thirty days is long enough to notice.
Git history
Every write is a commit naming the tool and the path. git log --stat over a suspicious afternoon shows exactly what changed, and reverting is ordinary git.
Exfiltration is the harder half. An agent persuaded to read people/ and summarise it has done so inside its own context, and this server has no view of what the client does next. The audit log will show that the notes were read, which is a record and not a defence. If a vault contains material you would not want summarised into a chat window, it should be a separate vault reached by a separate user, and probably not by an agent at all.
raw/ and anything imported as untrusted input. The wiki-raw layout separates collected material from written material for editorial reasons, and the separation happens to be exactly the boundary you want here.
No outbound calls, and nothing on disk
secondbrain makes no outbound network requests. Not for link previews, not for embeddings, not for telemetry, not for update checks. There is no URL fetcher, no webhook, and no tool that takes an address. The single exception is the git push you configure yourself with SECONDBRAIN_GIT_REMOTE, to a remote you chose.
This removes a whole class of problem before it starts. An agent that has been talked into something cannot make the server fetch an internal address, cannot use it to probe a network it should not see, and cannot use an outbound request as an exfiltration channel. There is no allow-list to maintain because there is nothing to allow.
Credentials follow the same principle. Registered clients, authorization codes, access tokens and refresh tokens live in process memory and are never written to disk. Restarting the container invalidates every session — a real inconvenience, accepted because it means the volume you back up and the volume somebody might one day walk off with contain notes and nothing else.
Passwords, when given as bcrypt:, are never present in plaintext anywhere: not in the environment, not in the config file, not in memory beyond the moment of comparison.
The audit log contains no note text
One structured line per tool call goes to stdout: who, which vault, which tool, which path, how many bytes, how long it took. That is the entire record. No content, no snippets, no search queries rendered with their hits, no diffs — at any log level, debug included.
{"ts":"2026-07-30T14:02:11Z","level":"info","event":"tool",
"user":"andreas","vault":"personal","tool":"note_section_edit",
"path":"wiki/postgres.md","bytes":184,"duration_ms":7}
The reasoning is worth spelling out, because logging more would be easy and would look helpful. A log is copied places the data is not: into a log aggregator, into a monitoring service, into a support ticket, onto somebody's laptop while debugging. An audit log that quoted note content would be a second copy of the knowledge base with weaker access control and no retention policy. The log is deliberately less useful than it could be so that it is not worth stealing.
What you can answer from it: which notes an agent touched and when, whether a burst of writes happened, how long a search took, whether a session did something at three in the morning. What you cannot answer from it: what any note says. For that there is git, which is inside the volume where the notes already are.
The metrics endpoint
The optional Prometheus endpoint is held to the same rule, and it is off by default. What it publishes is counts and timings: notes, words and bytes per vault, broken links, orphans, open tasks, tool calls by tool and outcome, cumulative durations, login attempts by outcome. What it never publishes is a path, a title, a tag or a line of note content — no metric carries any of them, and there is no label that could. The route label on secondbrain_http_requests_total is drawn from a fixed list of known routes rather than from the request path, so a caller cannot mint labels, or grow the series count without bound, by asking for invented URLs.
That restraint does not make an unprotected endpoint harmless. Counts are still the shape of somebody's private notes: how much they write and when they stopped, how large the work vault is next to the personal one, whether anyone logged in last night and how often they failed. Treat those numbers as belonging to the same person the notes do. Two mechanisms are available and they compose — a shared key of at least sixteen characters, compared in constant time, and a second listener on a port you simply do not publish. The second is the stronger: a port bound inside the Docker network and never mapped cannot be reached from outside whatever the key is. The endpoint also carries its own rate limit bucket, so a scraper presenting a wrong key cannot be turned into a timing oracle and cannot exhaust the limiter that protects the login page.
With metrics on and neither a key nor a private listener configured, the server logs metrics_unprotected once at startup, naming the path and hinting at both fixes. It is a warning and not a refusal, because on loopback an open endpoint is a reasonable thing to want. On a published port it means the opposite: anyone who can reach the server can read those counts, and that one line in the log is the only notice there will be. The metrics documentation lists every series and how to protect it.
What running without TLS exposes
The container speaks plain HTTP and does not terminate TLS. On a laptop, over loopback, that is fine. Across any network it is not, and the consequences are worse here than for most services.
- Every note that crosses the wire is readable in transit. Search results, note bodies, the diffs from a dry run. Anyone on the path sees your knowledge base as you use it.
- The bearer token is readable too, and a token is all that is needed to read everything else at leisure.
- The password crosses the wire on the login page. The OAuth flow includes an HTML form; over HTTP that form posts a password in the clear.
- PKCE does not save you. It protects an intercepted authorization code from being exchanged by a different client. It does nothing for a plaintext transport where the resulting token is visible anyway.
Put a reverse proxy in front of it and do not publish port 2020 alongside. The deployment guide has three working setups. If the server is only ever reached from the machine it runs on, bind it to 127.0.0.1:2020 and leave it there.
A compromised MCP client
If the client is compromised, this server is compromised with it, and there is no mechanism here that changes that. The client holds a valid token, and every request it makes is indistinguishable from a legitimate one because it is a legitimate one.
This is worth being clear about because MCP servers are sometimes described as if they contained a client. They do not. The trust boundary runs between the network and the container; the client is on the trusted side by construction. What that implies in practice:
- The security of your notes is bounded by the security of the machine running the MCP client, and by the model provider you have chosen to send them to.
- Revocation is restarting the container. Tokens are in memory, so a restart invalidates every session immediately — a blunt instrument that is always available.
- The audit log tells you what was read after the fact. It does not prevent anything.
- A read-only user for an agent that does not need to write is the cheapest real reduction in blast radius available, and it survives a compromised client.
Honest limits
Stated plainly, because software that oversells its guarantees is worse than software that has none.
No encryption at rest
Notes are Markdown files with normal permissions. Anyone with the volume, a backup of it, or root on the host reads everything. This is a direct consequence of the central promise — files you can open without this software — and it will not change. Use full-disk encryption on the host if that matters, which for a private knowledge base it probably does.
No per-note ACLs
Access is per vault. A user who may open a vault may read every note in it; there is no way to hide a directory, a tag or a single file from an otherwise permitted user. If a subset of your notes needs different access, it needs a different vault. Per-directory rules are on the roadmap, honestly labelled as considered rather than committed.
No protection from the host
Root on the machine means the notes, the git repository and the process memory. This server is a boundary between an agent and a mistake, not between an attacker and a filesystem.
No content review
Nothing inspects what an agent writes. A plausible, well-formatted, entirely wrong paragraph is written exactly like a correct one. The defence is that it is a commit you can read and revert, not that it was prevented.
Rate limiting is coarse
There is no per-tool throttle. An agent in a loop can produce a great many commits quickly. They are all revertible and none are destructive, but the repository will show it.
The trash has a horizon
Thirty days by default, then it is purged. After that the safety net is git, and if versioning is off there is no safety net at all beyond your backups. Leave SECONDBRAIN_GIT on.
Reporting
Security problems can be reported through GitHub security advisories on the repository, which keeps the report private until there is something to say. Please include a reproduction; a path that escapes a vault, a tool that writes without a hash check, or a log line containing note text are all worth a report.
This is a single-author MIT project, not a product with an on-call rotation. Expect a considered answer rather than a fast one.