Privacy โ what leaves your machine
Tusk's Vault is local-first by design. This page is the data answer: what stays on your machine, what travels over the network, where every secret is stored, and what that does not protect you from.
For the threat model โ what counts as a vulnerability here, the STRIDE breakdown per feature, and how to report one โ see Security policy.
๐ The guarantees
Each claim first, then the part that qualifies it. Nothing here is a promise the code does not keep.
A fresh clone holds zero credentials, zero lore, zero personal state. Source and templates only โ see the next section for the file-by-file split.
Your documents never leave your machine as files. What travels per
question is the text the prompt carries: your question, the clarifications
that matched it, and your lore.
โ The caveat that matters: with the default folder source there is no
per-question retrieval, so the whole parsed corpus goes into every prompt,
truncated at a document boundary past 500,000 characters (KB_CHAR_LIMIT).
The Obsidian source narrows, and only once a vault
outgrows the budget. Assume your provider sees your campaign.
The dashboard binds to 127.0.0.1. Other devices on your LAN cannot reach
it.
โ If you set HOST=0.0.0.0:
- The API does not authenticate requests.
host-origin-guard.tsenforces its loopbackHostcheck only while the bind is loopback โ a non-loopback bind switches that check off rather than widening it.- The cross-origin
Origincheck on writes still runs. - The routes that act on the host โ the Claude Code spawn, choosing a lore
folder, updating, the diagnostics bundle, the log, approving an MCP pairing,
and writing a persona's prompt โ stay gated on the peer address by
loopbackOnly(). - Two writes are not gated, on purpose:
/api/keysand/api/discord. On a widened bind, anyone who can reach the dashboard can set an API key or the bot token. That is the tradeHOST=0.0.0.0asks you to accept, and it is why the recommendation is to widen it only on a network you control.
API keys are encrypted at rest โ AES-256-GCM in your per-user config directory, key derived by scrypt from a machine identity. Masked in every response.
โ Honest framing, and keys/crypto.ts says the same: this is obfuscation with
authenticated encryption, not high-grade cryptography. The salt sits beside the
ciphertext, and anything running as you can derive the key. What it defeats is a
backup client or support bundle scooping up a readable key file โ a likelier
path to a leaked key than a local attacker.
Embeddings run locally, via MiniLM-L6-v2. Your clarification text is never sent anywhere to be embedded โ though a clarification that matches a question is then quoted into that question's prompt, and reaches your provider the way your lore does.
No telemetry, no analytics, no phone-home. Every host this app can reach is listed in the next section, so you can check the list against the code.
OpenRouter requests pin a zero-data-retention floor โ zdr: true and
data_collection: deny on every call, including model-probe.ts, or a model
would test reachable and then fail every real question. It is why most :free
variants are unavailable: they run on hosts that keep prompts.
โ The one way past it is deliberately awkward: an opt-in recorded per model
id, visible everywhere that model is shown, revoked automatically when you
pick a different model.
You can audit every byte. ~29,000 lines of TypeScript, plus ~9,700 of tests sitting next to the code they cover.
๐ Every host it can reach
grep -rn 'fetch(' src/ finds every HTTP call in the app. The only one it
misses is the updater, which goes out through git.
| When | Where | Carries |
|---|---|---|
| Per question | Discord's gateway | The mention and the reply |
| Per question | Your chosen provider's API | Question, system prompt, matched clarifications, lore |
| Per question, if a file is attached | Discord's CDN | Downloads the attachment to parse it |
| Model browser, keyless | OpenRouter catalogue + host-policy directory | Nothing. Cached 24 h |
| Model browser, โค once a day | kochitusker.github.io/โฆ/grades.json | Nothing โ a static file, no key, no query string, no identifier |
| Once, first boot | Hugging Face | Downloads MiniLM (~25 MB), then cached forever |
| When you update | GitHub, via git fetch / pull --ff-only | The same traffic a manual git pull makes |
| Every dashboard load | fonts.googleapis.com / gstatic.com | Your IP, User-Agent, referring page |
| Never | Ollama | Loopback-only, enforced by the SSRF guard in routes/settings.ts |
Two of those deserve a note.
- The grades feed exists so a model graded after your version shipped still shows a grade. If it fails you get the cached copy, then the grades your build shipped with โ nothing breaks, nothing retries in the background.
- The fonts are a plain
@importon the first line ofsrc/index.css: the one third-party request the dashboard makes on its own, and it happens before you configure any provider. If that bothers you โ reasonably โ delete that line and the dashboard falls back to system fonts.
๐ What's in a fresh clone vs. what's created at runtime
A fresh clone contains (~357 tracked files)
Source code, setup scripts, the launcher, and configuration templates only. Nothing in this list contains personal data or credentials:
- Source code (
src/,server.ts) - Installer, setup + launcher scripts (
install-tusks-vault.bat/.sh,setup.bat,setup.sh,run.bat,run.sh) and the uninstaller (uninstall.bat,uninstall.ps1,uninstall.sh) - The updater script (
scripts/update.mjs) .env.exampleโ env-var template, no valuesLore/README.mdโ placeholder README explaining the lore folder (the actual documents go in a siblingTusks-Lore/directory; see How it's built for details)- Standard files (license, README, docs, package files, tsconfig, etc.)
Verify the count yourself with git ls-files | wc -l; it moves with every release, so treat the figure as an order of magnitude rather than a checksum.
Files created at runtime, on your machine, gitignored
These files appear during use and never get pushed. They land in three places โ the repo, your resolved lore folder, and your per-user config directory (env-paths' tusks-vault config location, overridable with TUSKS_VAULT_CONFIG_DIR). Which one a file lands in is a deliberate choice: campaign state follows the campaign, per-install state stays with the install.
In the repo:
| File / folder | When it's created | What it holds |
|---|---|---|
node_modules/ | First npm install | Dependencies. |
node_modules/.tv-lockfile-snapshot | After every successful install | Copy of package-lock.json from the last successful install. run.bat / run.sh use it to auto-detect "deps changed since last boot" and re-run npm install before launch (fixes ERR_MODULE_NOT_FOUND after git pull). |
models/ | First server boot | Local embedding model cache (~25 MB). |
.port-runtime | Every server boot | The port the server actually bound to. |
.env.local | When you save your Discord token | Discord App ID + Bot Token. |
settings.json | When you save settings | Bot name, models, thresholds, surface toggles, etc. |
.diagnose/latest.md | Automatically when a Discord reply errors; on demand via POST /api/diagnostics/bundle | Settings, lore-resolution state, git position and a scrubbed log tail. Keys appear only as 6-character SHA-256 fingerprints and lore content never appears โ but it is written to be pasted into a chat window, so read it before you paste it. |
run.log | Each run.bat invocation (optional) | A copy of the launcher's terminal output. |
In your lore folder โ the sibling Tusks-Lore/ if you have one, otherwise <repo>/Lore/ โ so campaign state survives a clean reinstall:
| File / folder | When it's created | What it holds |
|---|---|---|
| the folder itself | First "Create Tusks-Lore folder" click in the dashboard (or first dashboard upload, for the repo-local fallback) | Your campaign documents (PDFs, DOCX, MD, โฆ), Tomes' tusks-lore.json metadata, Tomes-written session chronicles under Sessions/<campaign>/. |
clarifications.json | When you record your first clarification | DM clarifications. |
clarifications.embeddings.json | When the first clarification is embedded | Base64-encoded 384-dim vectors for semantic matching. |
lore_gaps.json | When the bot first hits an off-corpus question | Questions the bot couldn't answer. |
logs/tusks-vault.log | First server boot | The captured server log, run through the secret scrubber. |
In your per-user config directory โ per-install, machine-bound or rebuildable, and deliberately not carried around with a campaign folder:
| File | When it's created | What it holds |
|---|---|---|
keys.enc + keys.salt | When you add your first LLM key | Your provider keys, AES-256-GCM encrypted and machine-bound; written atomically, 0o600 on POSIX. Keys are never written to the repo. |
mcp-clients.json | When you approve an MCP or Foundry pairing | One record per paired client: a SHA-256 hash of its bearer token (never the token), its origin, and which surface it speaks for. |
openrouter-models.json | First time the model browser opens | The public OpenRouter catalogue, cached 24 h. Prices and capability flags โ no account data. |
vault-map.<id>.json | When you build an Obsidian vault map | One-line digests of your notes plus their embeddings. This is derived from your vault and reads like your vault; the vault itself is never written to. |
Need to verify your setup?
Hit GET /api/diagnostics from the dashboard (or via curl) and you'll get a JSON report on the eight paths that hold your state โ keys.enc, .env.local, settings.json, the two clarification files, lore_gaps.json, the resolved lore folder (with which resolution rule won), and the model cache โ each with its absolute path, whether it exists, its size, and its last modified time. Useful for debugging "where did my settings go?" type questions.
๐ก๏ธ What this does not protect you from
The formal version lives in SECURITY.md โ a STRIDE table of what Vault does for you and what you should do, per-feature addenda for Ollama, Claude Code, Obsidian, Foundry and personas, and the list of what is still open. It is one page rather than two because a second copy of a security posture is a copy that goes stale, and the stale one is the one somebody trusts.
The short version, for a GM deciding whether to install:
- Loopback is a machine boundary, not a user boundary. A second account on the same box is inside it.
HOST=0.0.0.0widens trust, not just reach. There is no auth in front of state-changing endpoints. Keep the default bind unless you trust every device on your network.- Players can pull more than they have discovered. Vault answers from the
whole corpus and has no notion of what your table has found. The
allowPlayersceiling is off by default, so a fresh install answers only the GM. See Foundry VTT. - Document parsers are not sandboxed. Don't drop PDFs from strangers into your lore folder.
๐ Where every secret lives
| Secret | File | Format | Sent over the network to whom |
|---|---|---|---|
| LLM API keys | keys.enc + keys.salt in the per-user config dir | AES-256-GCM, machine-bound | Only to that provider's API, only when generating an answer that uses that key |
| Discord bot token | .env.local | Plaintext key=value | Only to Discord's gateway during bot login |
| MCP / Foundry bearer tokens | mcp-clients.json in the per-user config dir | SHA-256 hash only โ the token itself is handed to the client once at pairing and never stored | Never sent by Vault. The paired client presents it back on each request |
| GitHub PAT (maintainer dev-mode updates only) | Nowhere โ memory only, wiped on restart | โ | Only to api.github.com, to verify the token reaches the private dev repo |
| MiniLM model weights | models/ | HuggingFace cache | Downloaded once from huggingface.co, then offline forever |
| Your campaign documents | Tusks-Lore/<files> (or <repo>/Lore/<files>) | Whatever you dropped in โ PDF, DOCX, MD, etc. | The parsed text goes to the LLM provider you chose for that query. With the folder source that is the whole corpus, every query, up to the 500 K-character cap. The files themselves are never uploaded |
| Your clarifications | clarifications.json + .embeddings.json in the lore folder | JSON + base64 Float32 | The embeddings never leave this machine โ matching runs locally. A clarification that matches a question is quoted into that question's prompt, so it goes to the same provider the answer comes from |
| Your Obsidian vault | Wherever you keep it โ Vault only ever reads it | Your own notes | To your provider, as prompt text: the whole vault while it fits the prompt budget, and above that the map's digest plus the notes a question selects. The vault is opened read-only, enforced by readonly-guard.test.ts, which scans the reader's own source for filesystem mutators |
Nothing in this table is sent to a Tusk's-Vault-controlled server. We don't run one.
Next steps
- ๐ Known issues + active hardening backlog
- ๐ง How retrieval works under the hood
- ๐ธ What it costs โ the pricing table that used to live on this page
- ๐ Choosing a provider โ what each one sees
- ๐ฆ Dependencies and why each is here