This document describes what muninn guarantees, what it deliberately does not
guarantee in v1, and how to widen or narrow its visibility.
| Concern | Stance |
|---|---|
Claimed scope keys (agent, user, …) |
Bound to the bearer when MUNINN_HTTP_TOKENS_FILE is set (HTTP transport): each token may only name the scopes its grant covers. Without a tokens file, scope keys are trusted claims, as in v1. |
Path traversal (.., absolute paths, symlink escape) |
Prevented. Structurally impossible to escape the vault root. |
| Cross-scope access inside the agents folder | Structurally impossible. The resolver always appends the caller's own scope. |
Cross-scope leakage via [[wikilinks]] in shared notes |
Prevented. A shared note linking to the caller's own scoped note is refused with write_denied; only the owning scope's suffix is ever persisted, and only in files that scope alone can read. |
| Writes into human-owned regions | Governed by policy. Denied unless the active policy permits it. |
| HTTP endpoint exposure | Optional static bearer token and/or per-tenant scoped tokens. Loopback-only by default. |
Scope keys are supplied per tool call, not per session. By default they are trusted claims: a single server process serves many agents concurrently with no session handshake, and any client that clears the endpoint gate may name any scope. That is fine for a loopback sidecar whose clients are all launched by the same operator — and dishonest for a shared server reachable over the network.
For the shared deployment, the HTTP transport supports per-tenant scoped
tokens via MUNINN_HTTP_TOKENS_FILE, a JSON file mapping bearer tokens to
scope grants:
{
"tokens": [
{ "token": "jarvis-secret", "scopes": { "agent": "jarvis", "user": "*" } },
{ "token": "ops-secret", "scopes": { "agent": "friday", "user": "tony" } }
]
}- Grant keys MUST be exactly the active scheme's placeholders; each value is an
exact string or the total wildcard
*(partial patterns like"t*"are rejected at startup). A token listed in several entries gets the union of its entries — a request is permitted when at least one entry matches every key. - Authentication happens in the transport middleware: when the tokens file
is configured, every request to
/mcpand/v1/contextmust present either a configured scoped token or the staticMUNINN_HTTP_BEARER(which keeps its semantics as the operator token and carries the all-scopes grant); anything else is401. The probe routes stay open. - Authorization happens at scope validation, where every scoped surface
already funnels: a
tools/call, thesession-contextresource and prompt, andGET /v1/contexteach check the requested scope keys against the presenting token's grant. A mismatch is thescope_denieddomain error (HTTP403on/v1/context), rejected before any path resolution or IO. The message names the offending key and never enumerates valid grants. - The file is read once at startup; an unreadable or invalid file is a startup
error, not a silently open server, and token values never appear in logs or
--print-configoutput. Rotation requires a restart — grants are resolved per request from the startup table, so a token dropped from the file stops authorizing on the next restart, even on already-open sessions. - Standard secret-mount practice applies: keep the file readable only by the
server's user (e.g. a Kubernetes
Secretvolume).
Without a tokens file, MUNINN_HTTP_BEARER alone still protects only the
endpoint, not individual tenants — a coarse gate, not per-scope
authorization. The stdio transport is unchanged either way: the launching
process owns the vault, so process-level trust applies by design.
Operational guidance: for shared or remote deployments, configure
MUNINN_HTTP_TOKENS_FILEso every client is confined to its own scopes, and reserveMUNINN_HTTP_BEARERfor operator tooling. Expose an unauthenticated or static-bearer-only server just to clients you trust to honestly declare their own scope.
The HTTP transport also enforces DNS-rebinding protection on the inbound
Host header. By default only loopback hosts (localhost, 127.0.0.1, ::1)
are accepted; off-host clients addressing the server by a Kubernetes Service DNS
name or ingress hostname must be allow-listed via MUNINN_HTTP_ALLOWED_HOSTS,
or their requests are rejected with 403. Keep the list as tight as the
deployment allows; reserve the * opt-out for cases where an upstream proxy or
ingress already validates Host.
Every client-supplied virtual path is validated and normalised:
- empty paths, embedded NUL bytes, absolute paths, and any
..component are rejected before resolution; - after resolution, the deepest existing ancestor of the target is canonicalised
and must lie within the canonical vault root — this catches
..survivors and symlink escapes (a symlink inside the vault pointing outside is refused); - anything resolving outside the vault root is always denied, regardless of policy.
Inside the agents folder, with a non-empty scheme, the resolver appends the
caller's rendered scope as both the first directory segment and the file-stem
suffix on every read, write, edit, and delete. A request for PERSONA.md from
{agent: jarvis, user: tony} can only ever resolve to
Agents/jarvis.tony/PERSONA.jarvis.tony.md. There is no virtual path — legitimate
or crafted — that addresses another scope's file: a crafted path such as
Agents/PERSONA.jarvis.sam.md still resolves under jarvis.tony and lands at
Agents/jarvis.tony/PERSONA.jarvis.sam.jarvis.tony.md, never sam's file. Listings
likewise filter by the caller's own suffix, so other scopes' files are invisible.
An empty scheme (MUNINN_VFS_SCHEME=) disables suffixing: the agents folder
degenerates into a plain shared directory governed by the policy's outside-folder
rules, with no own-scope isolation.
[[wikilink]] and relative markdown link targets in note content are rewritten on
the same own-scope boundary as filenames. On read, the caller's own suffix is
stripped from every target, so an agent only ever sees clean shortest names and
never another scope's suffix. On write, a link to the caller's own scoped note is
rewritten to its suffixed physical form — but that form is only ever stored inside
the caller's own scope, which no other scope can read.
The one direction that could leak is a shared note (readable by every scope)
linking to the caller's own scoped note: persisting the suffixed target would
expose the scope's existence to every reader. This is refused before any bytes are
written, with code write_denied and a message naming the offending target. With
an empty scheme there are no scopes and the transform is a no-op.
There are exactly two regions: inside the agents folder and outside it but
still within the vault root. One server-wide policy (MUNINN_POLICY) governs
both:
| Policy | Inside agents folder | Outside agents folder |
|---|---|---|
scoped |
own-scope read/write | denied (path_not_permitted) |
namespaced (default) |
own-scope read/write | read-only (writes → write_denied) |
readonly |
own-scope read-only | read-only (writes → write_denied) |
readwrite |
own-scope read/write | read/write |
The distinction between the two refusal codes is deliberate: a region that is
entirely unreachable (scoped outside) reports path_not_permitted and does not
confirm whether a file exists; a region that is readable but not writable reports
write_denied. When the agents folder is the vault root (MUNINN_AGENTS_DIR=.),
the "outside" region is empty and that column has no effect.
To avoid surfacing editor/VCS noise and to keep agents from trampling tool state, two filters apply to listing and to direct read/write/edit/delete:
- Hidden filter — any path segment beginning with
.is excluded by default. The configured agents folder is exempt even if it begins with.(e.g.MUNINN_AGENTS_DIR=.agentsstays traversable). Toggle off withMUNINN_INCLUDE_HIDDEN=true. - Ignore-file filter —
.gitignoreand.obsidianignorepatterns are honoured hierarchically (the same machineryripgrepuses). Toggle off withMUNINN_HONOR_IGNORE_FILES=false.
Direct addressing of an excluded path returns path_not_permitted — not
not_found — so the filter does not leak whether the file actually exists.
Set MUNINN_INCLUDE_HIDDEN=true and/or MUNINN_HONOR_IGNORE_FILES=false when
an operator genuinely wants the agent to see hidden or ignored files. Both default
to the conservative setting.
Content recall (recall_memory_notes) is backed by per-scope in-memory indexes
plus one shared-region index — never a single combined index. A scope's notes
live only in that scope's index, and a query opens only the caller's own-scope
index plus (when the policy permits reading the shared region) the shared index.
Cross-scope recall is therefore structurally impossible, not merely filtered:
there is no index that holds two scopes' content, so no filter bug can leak one
scope's notes — paths, snippets, or scores — into another's results.
Indexes are seeded from the same visibility walk as list_memory_notes, so hidden
and .gitignore/.obsidianignore-excluded notes never enter any index and can
never surface as a recall hit. The index is held entirely in memory; nothing is
written to disk, so it never appears in the vault, in git, or in a listing.
Snippets pass through the same own-scope suffix strip as the read path, so a returned fragment never exposes another scope's filename suffix.
Internal errors are mapped at the MCP boundary into a human-readable message plus
a structured code. Messages reference the virtual path the client supplied,
never the resolved physical path, and raw OS error strings are never propagated —
IO failures carry only an error kind and a static context label.
- Hot reload of the tokens file (rotation currently requires a restart).
- CORS / auth presets for non-loopback HTTP deployments.