MemBerry is a persistent memory system for AI agents. It stores a Neo4j knowledge graph (episodic memories, semantic knowledge, temporal facts, entities, code symbols, audit log) with a Redis cache/stream layer, and exposes that graph to agents over the Model Context Protocol (MCP).
This document describes the supported deployment model, the security controls
that ship in the codebase, how to configure them, and a checklist for operators.
For the full asset/trust-boundary/threat analysis, see
THREAT-MODEL.md.
MemBerry is designed and hardened for a single-operator / single-trusted-host deployment:
- The MCP server, Neo4j, and Redis run on one host (typically via the bundled
docker-compose.yml), reachable from the agents the operator controls. - The MCP HTTP transport binds all interfaces by default and is intended to be
reached over the loopback interface, an SSH tunnel, or a private network you
control — not exposed directly to the public internet. Restrict it with
MEMBERRY_HOST=127.0.0.1, or under Docker with the publish address (see Network exposure). - Agents authenticate with a Bearer token. Each token can be mapped to a named actor for individual revocation.
- All callers are assumed to operate within one trust domain. MemBerry provides
project-scope isolation (logical separation of memories by
project:<name>tag) but not hardened multi-tenant isolation between mutually distrusting parties (see Residual risks and the threat model).
If you need to share an instance across mutually distrusting tenants, treat that as out of the current supported model and isolate at the deployment layer (separate processes, separate Neo4j/Redis instances, network segmentation).
The MCP HTTP/SSE transport requires a Bearer token by default. Token resolution
happens at server start in packages/mcp/src/server.ts (startSSE,
parseTokenPairs) with the following precedence:
- Single shared token — if
MEMBERRY_API_TOKENis set, that value is the token and is mapped to the actordefault. - Per-actor named tokens — if
MEMBERRY_API_TOKENSis set (and even ifMEMBERRY_API_TOKENis also set), eachname:tokenpair maps a distinct token to a named actor identity. - Unauthenticated opt-out — if no token is configured and
MEMBERRY_ALLOW_UNAUTHENTICATED=true, auth is disabled and a startup warning is logged. Development only. - Generated session token (fallback) — if no token is configured and the
opt-out is not set, the server generates a random
randomUUID()token and prints it to stderr on startup. This guarantees the server is never accidentally open: a missing config fails closed to a random secret rather than to no auth.
This precedence is narrowed once tenant tokens are configured: a token that authenticates but is not a tenant token is rejected outright — see Multi-tenant mode.
Tokens are presented as Authorization: Bearer <token>. Matching is done with a
constant-time comparison (timingSafeEqual, via actorForToken in
server.ts) to avoid leaking token bytes through response timing. A length check
precedes the comparison (required by timingSafeEqual), and only equal-length
candidates are compared.
The single-token mode (MEMBERRY_API_TOKEN) is the legacy/simple path. The
named-token mode (MEMBERRY_API_TOKENS) is preferred because it supports
per-actor revocation without rotating a shared secret for everyone.
# Generate a high-entropy token (example):
export MEMBERRY_API_TOKEN="$(openssl rand -hex 32)"Agents then send Authorization: Bearer <that value>.
# Comma-separated name:token pairs.
export MEMBERRY_API_TOKENS="alice:$(openssl rand -hex 32),ci:$(openssl rand -hex 32)"Each token authenticates as the named actor (alice, ci, …). The actor name is
the identity surfaced to downstream logic and audit.
- Rotate a token by changing its value in
MEMBERRY_API_TOKEN/MEMBERRY_API_TOKENSand restarting the server. Tokens are read once at startup, so a restart is required for changes to take effect. - Revoke a single actor by removing that actor's
name:tokenpair fromMEMBERRY_API_TOKENSand restarting. Other actors' tokens remain valid — this is the main advantage of named tokens over a single shared secret. - Treat tokens as secrets: store them in your secret manager / environment, never commit them. The generated fallback token is printed to stderr — capture it from logs only on a trusted host, and prefer setting an explicit token in any non-throwaway deployment.
Authentication answers who is calling; capability policies answer what that
caller may do. Set MEMBERRY_CAPABILITY_POLICIES_V1 to a JSON policy set and
each (tenant, actor) pair carries an explicit grant list of
scope/domain/tool/operation (ActorCapabilityGrantV1,
packages/core/src/capability-policy.ts). A per-session runtime interposer then
wraps every tool registration and denies any call the actor was not granted
(installCapabilityRuntimeInterposerV1, packages/mcp/src/capability-runtime.ts,
installed per session in server.ts). Every tool carries capability metadata,
including berry_tools itself — so enabling a domain is a gated operation, not
a free action.
Two deliberate refusals: the server throws at startup if policies are configured
alongside MEMBERRY_ALLOW_UNAUTHENTICATED=true (a policy keyed on an actor is
meaningless with no actor), and the stdio transport refuses to start with
policies configured (it has no per-session identity).
Policies are off by default. With the variable unset there is no interposer, and any authenticated caller can enable any domain — progressive disclosure is context hygiene, not authorization.
- Bind address. The HTTP/SSE transport binds all interfaces (
0.0.0.0) by default; the port comes fromPORTthenMCP_PORT(default3101). SetMEMBERRY_HOST=127.0.0.1(or the genericHOST) to restrict the process to loopback (resolveListenHostinserver.ts). Under the bundleddocker-compose.ymlthe container binds all interfaces on purpose (MEMBERRY_HOST, fed fromMEMBERRY_BIND_HOST, default0.0.0.0) so the server is reachable through Docker's port proxy — there the host publish addressMEMBERRY_PUBLISH_HOST(default127.0.0.1) is the actual access gate, and setting it to0.0.0.0should be a deliberate act. The startup log line printslocalhostwhen no bind host is set; that is the URL to use from the host, not a statement that the socket is loopback-only. - Origin allowlist. Browser-originating requests are checked against a fixed
localhost allowlist (
isOriginAllowedinserver.ts):http(s)://localhost,http(s)://127.0.0.1,http(s)://[::1]. Any otherOriginis rejected with403, including on the CORS preflight (OPTIONS). Requests with noOriginheader are allowed, since non-browser MCP clients do not send one — those still require a valid Bearer token. - CORS. Responses echo back the specific allowed origin (never
*) and only permitGET, POST, OPTIONSwithContent-TypeandAuthorizationheaders (setCorsHeaders). - Health endpoints.
GET /healthz— unauthenticated liveness check. It returns the service name, transport, session counts, anauth_requiredboolean, uptime, and consolidation-automation health (consolidation_automation), whose per-worker snapshot includesrunning_scope/queued_scopes— those are project scope names — and the last internal error string. No memory content and no secrets, but scope names and operational error text are readable by anyone who can reach the port. Intended for local service managers (systemd, Docker healthcheck) to verify the process is up without reading token files.GET /readyz— authenticated readiness check (same payload plus admission-shadow status,status: "ready"). Requires a valid Bearer token.
- Wiki viewer (port
3200) — a second, unauthenticated listener.startWikiViewer(packages/wiki/src/viewer.ts) is a separate HTTP server with no authentication of any kind: no Bearer check, no origin allowlist. It serves the compiled wiki and exposes mutating endpoints —/api/edit(reconciles edited markdown back into Neo4j and recompiles),/api/refresh,/api/settings/hooks-tuning, and/api/settings/hooks-install(runs the hook installer against the viewer's working directory). The edit round-trip is live whenever a Neo4j driver is wired, which both CLI entry points (wiki serve,wiki build) and therefore the bundledmemberry-wikicompose service always do. Like the MCP transport it binds all interfaces unless a host is set —MEMBERRY_WIKI_HOST(viewer-specific), thenMEMBERRY_HOST, thenHOST(resolveWikiHost). Keep3200on loopback (MEMBERRY_WIKI_HOST=127.0.0.1, andMEMBERRY_PUBLISH_HOST=127.0.0.1under Docker); anyone who can reach it can read all compiled memory and write to the graph. - Per-session servers. Each SSE connection and each Streamable-HTTP session
gets its own
McpServerinstance with a freshly registered, disabled-by-default tool set. Sessions are tracked by ID and torn down on close. - Graceful shutdown. On
SIGTERM/SIGINTthe server closes active transports and the Neo4j/Redis connections within a bounded timeout (MEMBERRY_SHUTDOWN_TIMEOUT_MS, default 5s) so a restart cannot wedge on a hung connection.
The berry_query tool (admin domain, disabled by default) accepts raw Cypher.
It is defended in depth (packages/neo4j/src/query.ts):
Layer 1 — static validation (validateReadOnlyCypher). Before execution the
query is:
- Unicode-normalized (NFKC) so compatibility/fullwidth homoglyphs (e.g.
DELETE) fold to ASCII and cannot slip past keyword checks. - Stripped of string literals, line/block comments, and
$paramreferences so keywords inside those constructs do not cause false positives or evasions. - Rejected if it contains an embedded
;(stacked statements), so a read query cannot be suffixed with a write. - Rejected if it begins with an administrative command (
SHOW,USE). - Rejected if it contains any mutating keyword (
CREATE,MERGE,SET,DELETE,DETACH,REMOVE,DROP,FOREACH,LOAD). - Rejected if it
CALLs a stored procedure. Read-onlyCALL { … }subqueries are permitted;CALL procName(...)is blocked.
Layer 2 — server-enforced read transaction (rawCypher). Even if validation
were somehow bypassed, the query runs in a Neo4j session opened with
defaultAccessMode: neo4j.session.READ. Neo4j itself rejects any write in a READ
transaction, so berry_query can never mutate the graph regardless of the input.
The result set is also capped (MAX_RAW_CYPHER_LIMIT = 100) and the query is
wrapped in a bounding CALL { … } RETURN * LIMIT n.
Important scoping caveat: berry_query / rawCypher is read-only but is
not project-scoped. A caller that has enabled the admin domain can read across
all projects in the graph. It is an administrative/diagnostic tool and is
disabled by default for that reason. See the threat model.
Set MEMBERRY_READONLY=true to run a strict read-only deployment. Reads and
retrieval continue to work; write paths are rejected:
berry_storethrows immediately at the top ofAMPService.store(packages/core/src/service.ts).- All memory-block writes (
insert,replace,rewrite,archive, and the underlying_persist) throw via_assertWritable()inMemoryBlockService(packages/core/src/blocks.ts), which is constructed with the read-only flag inservices-factory.ts.
This is useful for serving a frozen knowledge base, for read-replica style deployments, and for limiting blast radius when exposing memory to less-trusted agents.
The flag covers the MCP tool surface only. The wiki viewer's /api/edit writes
to Neo4j through its own driver and is not gated by MEMBERRY_READONLY, so a
read-only deployment must also keep port 3200 unreachable (see
Network exposure).
Set MEMBERRY_REDACT_ON_INGEST=true to redact common secret shapes from episode
content and task before they are hashed, embedded, or persisted
(AMPService.store, gated by config.redactOnIngest). Redaction happens at the
ingest boundary so credentials never enter the store — relying only on
export-time redaction would still leave plaintext in Neo4j and in backups.
The pattern set (packages/core/src/redact.ts) is deliberately conservative to
avoid mangling legitimate prose, and covers:
- OpenAI-style keys (
sk-…), Stripe secret/restricted keys (sk_live_…,rk_test_…), AWS access key IDs (AKIA…), GitHub tokens (ghp_…,gho_…,github_pat_…), Slack tokens (xox[baprs]-…), Google API keys (AIza…), JWTs, and PEM private-key blocks. key = valueassignments whose key name signals a credential —api_key,secret,secret_key,token,access_token,client_secret,private_key,password/passwd/pwd,passphrase,credential(s),aws_secret_access_key,aws_session_token,auth— in bare, spaced, and JSON-quoted form, so{"a":"1","password":"x","b":"2"}masks only the value.Bearer <token>(as in anAuthorizationheader), handled by a separate pass because theauthkey name cannot match the longer word "Authorization" and a bare value capture would stop at the space and leak the token.- Passwords embedded in connection-string URLs (
scheme://user:pass@host).
packages/core/src/redact.ts is the authoritative set; treat the list above as a
summary of it, not as a second source.
A second, independent redaction pass runs at the graph-export boundary
(packages/graph/src/allowlist.ts, used by GraphSnapshotService) so that
berry_graph_report / berry_graph_export never emit secrets even if redaction
was not enabled on ingest. Export also runs through a per-node-type property
allowlist and XSS-escapes values rendered into the interactive HTML map.
Redaction is best-effort pattern matching: it raises the bar significantly but is not a guarantee that no secret can ever be stored. Do not deliberately feed credentials into memory and rely on redaction to scrub them.
Write activity is recorded in an append-only audit log
(packages/neo4j/src/audit.ts). Each entry is an :AuditLog node
(actor, action, scope, target_id, detail, at) stored in the same
graph so the trail is queryable alongside everything else and survives restarts.
- The store has no update or delete API — the trail is append-only by construction.
append()is best-effort and never throws into the caller's path: losing an audit line must not fail a user operation. Failures are logged to stderr.- Indexes/constraints for the audit log are created by schema migration
0002-audit-log(packages/neo4j/src/migrations.ts): a uniqueidconstraint plus(at),(actor), and(scope)lookup indexes. - Query the trail via
AuditLogStore.query({ actor?, scope?, action?, limit? }), which runs in a READ session and capslimitat 500.
Current coverage: the audit append is wired on the store path (the
berry_store write). Block-level and admin mutations are not yet uniformly
audited — see Residual risks.
Every memory is scoped to a project tag (project:<name>):
berry_storerequires a project tag by default.resolveProjectTag(packages/core/src/service.ts) derives it from aproject:tag or a[project:<name>]prefix on the task/content, canonicalizes it to lowercase, fuzzy-warns on near-duplicate tags (Levenshtein ≤ 2, catches typos that would fragment a project), and auto-creates a placeholder project entity on first use.- If no project tag is present,
storethrows unlessMEMBERRY_REQUIRE_PROJECT_TAG=falseis set (kept for backward compatibility). - Retrieval (
berry_load) andberry_grepfilter by the supplied project scope/tags, so a correctly-scoped query returns only that project's knowledge.
Project scoping keeps unrelated projects' memories from contaminating each other within a single trusted deployment. It is advisory: a caller may pass any project tag. For separation between mutually distrusting parties, use tenant mode below.
Set MEMBERRY_TENANT_TOKENS="acme:tok_acme,globex:tok_globex" to turn on
multi-tenant mode. Each bearer token then binds its session to a tenant, and the
binding is enforced at the data layer, not advisory:
- Every write stamps
tenant_id(episodes, facts, blocks, consolidated semantics). - Every read filters by the caller's tenant (
tenantWhereinpackages/neo4j/src/tenant.ts): semantics, facts, blocks, and grep. A named tenant matches strictly; the implicitdefaulttenant also matches legacy rows with notenant_id, so enabling tenancy needs no data migration. - The assembled-context cache and store dedup are tenant-namespaced.
- Default-deny tool surface: a tenant session is served only tools proven
tenant-isolated (
TENANT_SAFE_TOOLS: load/store/grep/memory_*/timeline/ fact_diff/context/ask).berry_contextforces therankedstrategy for named tenants (thedeterministicpath queries un-tenant-stamped entities). Raw Cypher (berry_query) and the not-yet-tenant-scoped satellite domains (code/arch/wiki/graph/research) are withheld entirely from tenant sessions. - Fail-closed tenant binding. Once any
MEMBERRY_TENANT_TOKENSpair is configured, a token that authenticates but is not a tenant token is rejected with401— includingMEMBERRY_API_TOKENand everyMEMBERRY_API_TOKENSactor — because such a token would otherwise operate silently on the shareddefaulttenant (isAuthorized,packages/mcp/src/server.ts). Plan for this: turning on tenant tokens invalidates every existing non-tenant token.MEMBERRY_ALLOW_DEFAULT_TENANT=truerestores the legacy fallback and gives up that isolation; leave it unset.
Graduation to physical isolation. A tenant can be routed to its own
Neo4j/Redis via MEMBERRY_TENANT_DATASTORES (a JSON map of tenant → connection).
Such a tenant's sessions are bound to a dedicated service container at boot;
everything else stays on the shared instance with tenant_id filtering. This is
the escape hatch for a high-value/regulated tenant without forking the codebase.
Per-tenant operations. memberry tenant stats|export|delete --tenant <name>
counts, exports, or erases a single tenant's memory. delete refuses the default
tenant (which also owns legacy data) and requires --yes.
The cross-tenant guarantee ("tenant A never sees tenant B") is covered by
adversarial integration tests (tenant-isolation.regression.test.ts,
tenant-admin.test.ts). Single-tenant deployments (no tenant tokens) are
unaffected. The admin berry_query path remains read-only and unscoped — keep
the admin domain disabled in shared deployments.
- Set an explicit token. Configure
MEMBERRY_API_TOKENorMEMBERRY_API_TOKENSwith high-entropy values (e.g.openssl rand -hex 32). Do not rely on the generated fallback token for anything but a throwaway run. - Never set
MEMBERRY_ALLOW_UNAUTHENTICATED=trueoutside local dev. - Prefer named tokens (
MEMBERRY_API_TOKENS) so you can revoke one actor without disrupting others. - Do not expose the port publicly. The default bind is all interfaces, so
this is an active step: set
MEMBERRY_HOST=127.0.0.1for a direct/systemd run, or keepMEMBERRY_PUBLISH_HOST=127.0.0.1under Docker, and reach the server over a private network or an SSH tunnel. The origin allowlist and token are defense in depth, not a substitute for network controls. - Keep the wiki viewer off the network. Port
3200has no auth at all and accepts writes. SetMEMBERRY_WIKI_HOST=127.0.0.1(or leaveMEMBERRY_PUBLISH_HOST=127.0.0.1under Docker). - Never set
MEMBERRY_ALLOW_DEFAULT_TENANT=truein a multi-tenant deployment — it lets non-tenant tokens fall back to the shareddefaulttenant. - Change default datastore credentials. Replace the
memberry-local-devdefaults forNEO4J_PASSWORD/REDIS_PASSWORD(and keepREDIS_URLin sync) before any real deployment. Keep Neo4j (7687) and Redis (6379) off the public internet. - Turn on ingest redaction (
MEMBERRY_REDACT_ON_INGEST=true) when agents may handle credentials. - Use read-only mode (
MEMBERRY_READONLY=true) for any deployment that should only serve, never accept writes. - Keep the admin domain disabled unless you actively need it.
berry_queryreads across all projects. - Constrain ingest paths. Set
MEMBERRY_INGEST_ALLOW_DIRto bound whereberry_ingest/berry_braindump/berry_compilemay read and write. - Protect
OPENAI_API_KEYand other secrets via your environment / secret manager; never commit them. Use.env.exampleas a template, not a place for real values. - Run migrations on a trusted deployment. Schema migrations run at startup; ensure the configured Neo4j credentials are correct before first boot.
- Review the audit trail periodically (
AuditLogStore.query/berry_queryover:AuditLog) and ship stderr logs somewhere durable.
If you discover a security issue, please report it privately rather than opening a public issue.
- Contact: open a private report via GitHub Security Advisories.
- Please include a description, reproduction steps, affected version/commit, and any suggested remediation.
- We aim to acknowledge reports promptly and will coordinate a fix and disclosure timeline with you.
Please do not include live credentials or third-party data in your report.