Security Policy
Reporting a vulnerability
If you believe you have found a security vulnerability in Panella, please report it privately to [email protected]. Do not open a public issue for a suspected vulnerability.
Please include enough detail to reproduce the issue (affected version, configuration, and steps). We follow responsible disclosure: we ask that you give us a reasonable window to investigate and ship a fix before any public disclosure, and we will keep you informed of progress.
Trust model
Panella is self-hosted and owner-controlled. There is no Panella-operated service in the trust path — the box, its store, its tokens, and its governance config all live in the operator's own environment. The security posture below describes what that box enforces and what the operator is responsible for.
Two separate secrets, two separate channels
Panella distinguishes two credentials that must never be commingled:
- The owner bearer token — the agent / routing credential. It is presented as an HTTP
Authorization: Bearerheader to reach the memory surface, and it authenticates a principal (seepanella/http/auth.py). It is the key an agent uses to talk to the box. - The operator approval token — the approval identity proof. For the self-host
(
local_cli) transport this is the contents of a mode-0600token file that an operator presents to approve a queued durable write (seepanella/approval_transport.py). It proves "an authorized human said yes," not "an agent may call the API."
These are different secrets with different jobs on different channels. A bearer token can move data through the box; it cannot approve a durable write. An approval token proves approval identity; it is not a routing credential. Keep them stored and rotated separately. Handing an agent the approval token, or wiring the approval token in as a bearer, collapses the approval boundary — do not do it.
MCP network surface: opt-in, owner-only, DNS-rebind protected
The network MCP surface (/mcp) is off by default in the library — it mounts only when
PANELLA_MCP_ENABLED is set. The shipped Docker image, however, enables it
(PANELLA_MCP_ENABLED=1, see the Dockerfile / docs/SELF_HOST.md), so a compose/image
deployment has /mcp live (read-only mcp-read) on the facade port out of the box, while a
from-source library run does not until you set it. Wherever it is mounted it is defense-in-depth
gated (panella/http/app.py):
- Owner (root) principal required. A merely-valid bearer token is necessary but not
sufficient — the
/mcpgate authorizes the token as the governance root principal (403otherwise). A low-privilege or foreign-tenant token cannot borrow the MCP profile's authority. - DNS-rebinding / Host / Origin protection. The mount validates the request Host/Origin
before any auth work, rejecting a foreign Host up front. It is loopback-only unless the
operator explicitly sets
PANELLA_MCP_ALLOWED_HOSTS. - Per-token rate limiting on the MCP path.
- Read by default for WRITES; approval tools are advertised but execution-gated. The shipped
mcp-readprofile does not advertisememory.submit_candidate—mcp-writeadds it. But under the defaultlocal_cliapproval transport the operator approval tools (memory.list_pending_approvals/approve_candidate/reject_candidate) are advertised whenever that transport exists, i.e. even undermcp-read. Advertisement is not capability: the0600approval token plus the configured approver set gate execution, not visibility — without them an approve call is refused, and the finalizer's inert-closed default (empty approver set) refuses every finalize until approvers are configured. Submitted writes are candidates-only by construction — the MCP surface can never produce a direct durable write.
Serving self-check: refuse rather than serve blind
At startup the box runs a coherence self-check against its own store
(panella/store_probe.py). If the box's governance identity does not match its corpus — the
overlay forgotten, or a generic config pointed at an owned store — every read would die in a
tenant-isolation error and the box would silently serve nothing.
Instead, the self-check turns that silent dark-out into a loud refusal: the memory routes
return 503 (and the break-glass mint is refused) while /v1/health stays reachable so a
monitor sees a live-but-refusing process. A box that cannot prove it is serving its own corpus
coherently refuses to serve at all. A genuinely fresh box acknowledges emptiness explicitly
(PANELLA_FRESH_BOX=1).
Fail-closed defaults everywhere
The governance model is default-deny throughout (see docs/GOVERNANCE.md): a set-but-missing
overlay pointer crashes rather than silently serving generic (an unset pointer runs the shipped
generic base by design — a fresh box, not a wrong-deploy), an unknown approval transport is a
load-time error, an empty approver set approves nothing, and a provenance mismatch is refused.
Tenant isolation is enforced at the client boundary and fails closed on any ambiguous
attribution.
Scope and honest limits
Panella runs as a single-process service. Within that process, code holding the store credentials is inside the trust boundary — Panella does not claim in-process code isolation or cryptographic attestation between components. Its security boundary is the authenticated approval plus the provenance gate, not process-level sandboxing. Process isolation and attestation are tracked as future work, and the finalizer module documents this assumption directly.