The MMM Framework is software you deploy on infrastructure you control — there is no vendor-operated SaaS. This page is written for the buyer and the security reviewer: it covers how the platform isolates tenants, sandboxes code execution, protects secrets, and handles your data. Every control below ties to a specific source file you can audit, every claimed status is honored exactly (implemented, deployment-dependent, or partial), and where the current state is weaker than an enterprise buyer would want, that is stated plainly.
This page is the platform-trust companion to the deeper Security & AI Governance reference, which inventories the LLM-provider data flows, the kernel sandboxing tiers, and the hosted multi-user profile in full. Where the two overlap they agree; this page leads with the multi-tenant and data-handling story a procurement review asks for first.
Org-scoped storage, per-request authorization, and IDOR-safe 404s that refuse to confirm another tenant's projects even exist.
Agent-authored Python runs in a fail-closed container sandbox (cap-drop ALL, read-only rootfs, denied egress) under the hosted profile.
No hardcoded credentials; kernels launch with a scrubbed, allowlisted environment and deny-by-default outbound network.
The one server-side URL fetch resolves every DNS address and rejects loopback, RFC 1918, and cloud-metadata targets.
Security events are written as a SHA-256 hash-chained JSONL — any edit, delete, or reorder is detectable.
scrypt-hashed passwords and stateless HS256 JWTs today; the OIDC/SSO seam is stubbed and explicitly named as the next step.
Read the status labels literally. Implemented means it is in the code on the default path. Deployment-dependent means it is real but only in force under a named configuration (e.g. MMM_AUTH_ENABLED=1 or MMM_AGENT_HOSTED=1). Partial means a setting or seam exists but the full behavior does not yet — we say so rather than imply more.
Isolation rests on three layers — an org boundary on every stored record, an authorization check on every request, and IDOR-resistant error handling that refuses to leak existence.
Every storage record is stamped with an org_id at save time, and all list queries are org-scoped, so one tenant's listing can never include another's data (api/storage.py:107-110/181-182/424-427/505-508 stamping; :122-146/207-222/357-378/456-477/564-585 scoped list filters). A per-record assert_org_owns helper raises 404 on any mismatch (storage.py:637-657). Legacy records created before tenancy existed default to a fixed DEFAULT_ORG_ID (storage.py:26), and a one-time, idempotent backfill attaches orphan projects and users to a default organization (storage.py:660-696; src/mmm_framework/auth/store.py:298-345).
Project-scoped routes mount a require_project_access(min_role) dependency (src/mmm_framework/auth/deps.py:141-157). The agent API wires this dependency at 22 enforcement call-sites, and the root Models API routes wire it too. Roles are ordered (viewer → analyst → owner); a route can require a minimum.
The check is deliberately ordered to prevent project-existence probing. ensure_project_access (deps.py:119-138) raises 404 Not Found if the caller's org does not own the project — before it ever checks the role — and only raises 403 for an insufficient role once ownership is established. A tenant therefore cannot distinguish "this project belongs to someone else" from "this project does not exist": both return 404. That is the IDOR-resistant choice (return 404, not 403, on a cross-tenant reference).
Org / user / membership persistence is additive and idempotent — organizations and org_members tables, plus password_hash / org_id / status columns on users and an indexed org_id on projects (src/mmm_framework/auth/store.py:60-126; :131-149 creates organizations with slug-collision handling). The store is SQLite (sessions.db) in WAL mode (store.py:21,44).
deps.py:129-130), and the org-scoped list/assert helpers only activate when a non-None org is passed — i.e. for a real, non-dev principal. With auth off (the default install), there is one implicit tenant and these checks are no-ops. See section 6.org_id is stored in plaintext JSON sidecar files alongside the data, and the SQLite store has no row-level database ACLs or foreign-key-enforced tenancy. Correctness depends on each route actually mounting the access dependency. This is a code-level boundary, audited at the call-sites named above — not a database-enforced one.MMM_AGENT_HOSTED=1, the chat thread_id is treated as a bearer capability: the API refuses the guessable default and any client-invented or unknown thread_id, requiring a server-minted session, and refuses to drive another org's existing session (src/mmm_framework/api/main.py:450-465 rejects non-server-minted IDs with 403; :467-473 runs ensure_project_access on an existing session's project). In the default/dev posture, thread_ids are accepted and auto-created.
The agent executes LLM-authored Python through a kernel abstraction with three implementations selected by MMM_AGENT_KERNEL. The default, inprocess, runs code inside the API process and provides no sandbox — appropriate only for the single-user posture where operator and user are the same person. The subprocess tier adds process and secret isolation but, importantly, is not a sandbox (src/mmm_framework/agents/profile.py:26 — only container is in SANDBOXED_IMPLS). The OS-level sandbox is the container tier.
When MMM_AGENT_KERNEL=container, each session's kernel runs inside podman run (Docker-compatible) on a pinned image. The launch enforces (src/mmm_framework/agents/container_kernel.py:202-244): --cap-drop ALL and no-new-privileges, a read-only rootfs, cgroup memory (+swap) / pids / CPU caps, nofile/nproc ulimits, and a per-kernel exec-able tmpfs as the only writable area; podman applies its default seccomp profile.
A _verify_isolation gate (container_kernel.py:373-403) refuses to spawn a kernel unless read-only rootfs, cap-drop ALL, a memory cap, and a denied-egress posture are all present in the launch command. This gate is automatically required in the hosted profile (or via MMM_KERNEL_REQUIRE_SANDBOX). The hosted profile itself (MMM_AGENT_HOSTED=1, profile.py:29-65) is a single fail-closed switch: it force-upgrades the kernel to container, requires a complete sandbox, denies egress, drops CWD from the download roots, and the boot-time guard (api/main.py:156-158) refuses to start the server on a non-sandboxed kernel.
MMM_AGENT_KERNEL=container AND a built kernel image plus a container runtime (podman/docker) are present. The default kernel is inprocess (no sandbox), and subprocess is also not sandboxed.deploy/kernel/Containerfile:12-15).profile.py:4-7). Setting MMM_AGENT_HOSTED=1 without the image causes a refuse-to-start — by design. The governing rule: a half-applied sandbox is more dangerous than an honest single-user posture.
LLM/provider keys and cloud credentials are read from environment variables or Application Default Credentials only — ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, Vertex ADC, S3 keys via settings (api/config.py:33-44; src/mmm_framework/auth/config.py:34 for the signing secret). A repository grep for hardcoded high-entropy api-key / secret / password literals returned no matches.
Subprocess and container kernels launch with a fail-closed allowlisted environment (src/mmm_framework/agents/kernels.py:327-357, applied at spawn :573-576; container path filtered through the same scrub at container_kernel.py:140-152). A regex denylist strips anything ending in _API_KEY / _TOKEN / _SECRET / _CREDENTIALS / _PASSWORD / _PRIVATE_KEY, plus GOOGLE_APPLICATION_CREDENTIALS and MMM_LLM_API_KEY/CREDENTIALS_PATH — and the denylist wins over any passthrough (kernels.py:317-321 deny regex; denylist checked first at :349). The kernel never calls the LLM or embedder, so it needs no credentials.
Container kernels deny outbound network by default (container_kernel.py:154-192). The production ipc transport uses --network none — no metadata server, no egress; on Linux the tcp transport uses an --internal podman network. Egress is only opened via MMM_KERNEL_EGRESS=open; the default posture is deny, and the fail-closed gate (section 2) rejects a non-denied egress posture when a sandbox is required.
169.254.169.254 regardless of environment — that is an egress control (the container tier), not an env control. The subprocess tier is defense-in-depth, not a hosting green-light.MMM_KERNEL_SCRUB_ENV=0 returns the full os.environ (debug only, kernels.py:336-341), and the inprocess kernel shares the API process environment entirely — no scrub is possible there.tcp transport leaves egress open and unenforced, recorded as posture open:unenforced-macos-dev and audited at WARNING (container_kernel.py:187-190). Egress-deny applies to the container kernel only; inprocess/subprocess kernels have full host network..env. Secrets load from a .env file if present (api/config.py:16-20; auth/config.py:24). CORS allow_credentials defaults True with localhost dev origins (api/config.py:64-72) — review CORS before exposing the API.
The platform makes exactly one user-triggerable server-side outbound fetch: website brand extraction. It is SSRF-guarded in depth (src/mmm_framework/agents/brand_extract.py:48-143):
http or https only, ports 80/443 only, no embedded credentials (:48-83).getaddrinfo and asserts ip.is_global on each address, rejecting loopback, RFC 1918 private ranges, link-local 169.254.x (the cloud-metadata vector), ULA, multicast, and reserved space.follow_redirects=False, every hop is manually re-vetted, at most 3 (:99-143), with a byte cap and a content-type allowlist.brand_extract.py:14-17). This is a known, narrow residual.MMM_BRAND_FETCH_ALLOW=1 (:86-96).
Security and lifecycle events on the mmm_audit logger are written as a tamper-evident hash-chained JSONL (src/mmm_framework/agents/audit_sink.py:45-106). Each record's hash is sha256(prev_hash + canonical(record)) (chained at :98), so any edit, delete, or reorder breaks the chain and is detected by verify() (:131-157 detects a prev_hash break, a sequence gap, or a hash mismatch). The chain resumes correctly across process restarts (:59-77).
audit_sink.py:7-9).install_audit_sink() (:109-128; called from the FastAPI lifespan, src/mmm_framework/api/main.py:167-169). That install is best-effort — a failure is logged and swallowed rather than blocking boot — and it only runs when the API process starts; a standalone/library use of the framework that does not boot the API does not get the sink.
Passwords are hashed with scrypt, a memory-hard KDF (n=2**15, r=8, p=1, 16-byte random salt), in a self-describing scrypt$n$r$p$salt$hash encoding (src/mmm_framework/auth/passwords.py:26-29 params, :46-61 hashing). Verification is constant-time via hmac.compare_digest and never raises on malformed input (:64-85, compare at :85).
Session and access tokens are stateless HS256 JWTs signed with a shared secret (src/mmm_framework/auth/tokens.py:42-94). Decode verifies the signature with a constant-time compare (:77) and checks exp, nbf, audience, and issuer (:86-93). Tokens carry org, role, sub, and jti claims (:97-121), with a 1-hour access / 14-day refresh TTL (config.py:38-39).
The platform is built with an external-IdP seam already in place: an OIDCVerifier (JWKS fetch + RS256) is stubbed and build_verifier selects it on provider=oidc, raising a clear NotImplementedError until it is wired (tokens.py:149-166). SSO/OIDC is therefore a named, scoped next step rather than an unbounded rewrite.
MMM_AUTH_ENABLED defaults to False (config.py:30). When disabled, the request layer injects a single-tenant dev principal (is_dev=True, OWNER) that bypasses all tenant scoping (deps.py:50-66). A default install is single-tenant with no login.MMM_AUTH_ENABLED=1 and MMM_AUTH_SECRET is set — require_secret() raises otherwise (config.py:59-67)./auth/refresh is single-use (rotation), /auth/logout revokes, and a revoked_tokens denylist enforces it; deactivated users fail refresh. Access tokens are stateless, so they remain valid until their short TTL expires (≤ 1 h) — instant access-token kill is the remaining roadmap item.verify_password is passed None (service.py:74) and returns False immediately, so the unknown-user branch does not fully equalize timing against the wrong-password branch. This is a minor user-enumeration timing residual.There is no vendor SaaS: you run every component on infrastructure you control, and all agent and platform state lives on local disk (per-session workspaces, per-project knowledge-base sources, dataset uploads). The deeper data-flow inventory — what reaches an LLM/embedding provider and what stays local — is detailed on the Security & AI Governance page.
A storage_backend setting supports local (default) or s3 (api/config.py:37-44), with the local backend provisioning its directory at startup (api/storage.py:48-57). Honest limitation: the S3 backend is declared in settings but not yet implemented — StorageService read/write paths all use the local storage_path; there is no S3 code path present today. Treat S3 as a configured-but-not-wired option.
A data_retention_days setting (default 30, api/config.py:52) drives cleanup, and cleanup_old_jobs purges Redis job records older than the cutoff (api/worker.py:1268-1292). Honest limitation: retention currently purges only Redis job records — it does not auto-delete stored datasets, models, or results on disk. Deletion of data and models is on-demand via the delete_* methods on the storage service. If you require time-based deletion of stored data, that is an operational task on the disk store today, not an automatic sweep.
Output that crosses the kernel boundary is treated as untrusted egress. Captured Plotly figures and tabular tool output are schema-filtered to an allowed-key set, hard size-capped (MMM_PLOT_MAX_BYTES default 5 MiB, MMM_TABLE_MAX_BYTES default 1 MiB), and content-addressed with IDs salted by thread_id so identical payloads neither dedup nor become guessable across sessions (src/mmm_framework/agents/workspace.py:77-158). The size cap rejects (raises) rather than truncating.
File-download serving is TOCTOU-safe and path-traversal-guarded (src/mmm_framework/api/main.py:2723-2792): the resolved path must sit inside an allowlisted root, the realpath is opened with O_NOFOLLOW|O_CLOEXEC (rejecting a symlinked final component), the fd is confirmed to be a regular file (S_ISREG), and the response streams from that exact validated fd; traversal guards is_within/safe_join back it (workspace.py:202-226).
GET /plots/{id} and GET /tables/{id} — there is no separate per-tenant ACL on those two endpoints (workspace.py:89-92).main.py:2732-2733); this is defense-in-depth. O_NOFOLLOW falls back to 0 on platforms lacking it.Stated honestly: the platform is self-hosted software, so encryption at rest and in transit are properties of how you deploy it, not of a vendor-operated service.
allow_credentials defaults True with localhost origins (api/config.py:64-72); lock origins down before exposing the API.sessions.db (WAL mode) on local disk; knowledge-base embeddings are binary blobs in that same SQLite store. Encryption at rest is therefore whatever your disk / volume encryption provides (e.g. LUKS, a cloud encrypted volume) — the application does not add its own at-rest layer, and org_id and secrets in .env are stored in cleartext on that disk.localhost.In short: bring disk encryption and a TLS-terminating proxy. With both in place the deployment has standard encryption at rest and in transit; without them, it does not — and the platform will not silently claim otherwise.
Because there is no vendor SaaS, the security model is a partnership between what the framework provides and what you operate.
| The framework provides | You provide (operator responsibility) |
|---|---|
| Org-scoped storage, IDOR-safe 404 authorization, scrypt/JWT auth (when enabled). | Enabling auth (MMM_AUTH_ENABLED=1 + a strong MMM_AUTH_SECRET) and an SSO/reverse-proxy perimeter until OIDC lands. |
| The container sandbox, fail-closed isolation gate, scrubbed kernel env, deny-by-default egress. | Building the kernel image, providing a container runtime, and selecting the hosted profile / container kernel for multi-user use. |
| SSRF-guarded fetch, tamper-evident audit sink, size-capped data channels. | Shipping the audit log off-host to durable storage (the sink itself is installed by the app at boot); reviewing CORS origins. |
| A local storage backend and on-demand deletion methods. | Disk/volume encryption at rest, TLS termination in transit, network perimeter, backups, and time-based data deletion if required. |
| Documented data flows; raw datasets stay local by architecture. | Choosing the LLM/embedding provider (or LM Studio for fully-local), and pre-anonymizing sensitive rows the agent might surface. |
So this page stays credible, here is what is not done yet — each tied to the same source the claims above are:
OIDCVerifier seam exists and is selected on provider=oidc, but raises NotImplementedError (auth/tokens.py:149-166). HS256 symmetric tokens are the only live path today.revoked_tokens denylist), but access tokens are stateless and stay valid until their short TTL expires (≤ 1h). A token_version claim would close this.org_id lives in plaintext sidecars and an SQLite store with no row-level ACLs; correctness depends on routes mounting the access dependency.api/storage.py).data_retention_days purges only Redis job records; dataset/model deletion is on-demand (api/worker.py:1268-1292).subprocess and inprocess are not sandboxes.The direction of travel is clear from the seams already in the code: OIDC/RS256 verification behind the existing verifier interface, a token denylist keyed on jti, an S3 storage path, on-disk retention sweeps, and an off-host audit shipper.
Every control on this page traces to a file you can read. For the full LLM-data-flow and kernel-governance reference, see the security page; for anything not covered, audit the source directly.