Trust & Security

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.

At a Glance

Multi-tenant isolation

Org-scoped storage, per-request authorization, and IDOR-safe 404s that refuse to confirm another tenant's projects even exist.

Sandboxed code execution

Agent-authored Python runs in a fail-closed container sandbox (cap-drop ALL, read-only rootfs, denied egress) under the hosted profile.

Secret hygiene & egress

No hardcoded credentials; kernels launch with a scrubbed, allowlisted environment and deny-by-default outbound network.

SSRF protection

The one server-side URL fetch resolves every DNS address and rejects loopback, RFC 1918, and cloud-metadata targets.

Tamper-evident audit

Security events are written as a SHA-256 hash-chained JSONL — any edit, delete, or reorder is detectable.

Authentication

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.

1. Multi-Tenant Isolation Implemented Enforced when auth enabled

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.

Org boundary on storage

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).

Per-request authorization

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.

IDOR-safe 404s

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).

How the boundary is defined and enforced

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).

⚠️ Honest scope of the boundary

  • Enforcement is gated on auth being enabled. The org checks short-circuit for the single-tenant dev principal (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.
  • Isolation is enforced in application code, not by the datastore. 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.
  • Hosted chat sessions add a capability layer. Under 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.

2. Code-Execution Sandboxing Deployment-dependent

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.

The container sandbox

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.

Fail-closed isolation gate

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.

⚠️ Requires the image to be built and deployed

  • The container sandbox is active only when 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.
  • The cap-drop / read-only controls are applied by the provisioner at run time, not baked into the image (deploy/kernel/Containerfile:12-15).
  • The hosted profile is deliberately inert until the container sandbox image and runtime exist (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.
  • The gate verifies the command string it builds, not the runtime's actual applied state — defense-in-depth, not a kernel-level proof.

3. Secret Hygiene & Egress Controls Implemented Egress deployment-dependent

No hardcoded credentials

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.

Kernel environment scrubbing

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.

Egress controls

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.

⚠️ Where scrubbing and egress do not reach

  • Env-scrub does not block the cloud metadata server. On a cloud VM, Application Default Credentials are obtainable from 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.
  • The opt-out and the in-process case. 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.
  • macOS dev egress is open. On macOS dev boxes the 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.
  • Protect your .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.

4. SSRF Protection Implemented

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):

⚠️ Documented residuals

  • There is an acknowledged DNS-rebind window: the address check happens just before connect and the HTTP client re-resolves (brand_extract.py:14-17). This is a known, narrow residual.
  • The fetch runs in the API process only (host-side), never in the kernel, and is disabled in the hosted profile unless MMM_BRAND_FETCH_ALLOW=1 (:86-96).

5. Audit Logging Implemented

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).

⚠️ Tamper-evident, not tamper-proof

  • The JSONL is local. The chain makes on-host pre-shipping tampering detectable; true tamper resistance requires the documented off-host shipper to durable storage (audit_sink.py:7-9).
  • The sink is installed by the API app at startup via 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.

6. Authentication Implemented Off by default

Password hashing

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).

Tokens (JWT today)

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).

SSO / OIDC-ready

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.

⚠️ Authentication is OFF by default

  • 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.
  • Multi-tenant isolation is only in force when MMM_AUTH_ENABLED=1 and MMM_AUTH_SECRET is set — require_secret() raises otherwise (config.py:59-67).
  • HS256 uses a symmetric shared secret. The OIDC/RS256 external-IdP path is the explicit stub above. Refresh-token revocation ships/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.
  • On an unknown user, 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.

7. Data Handling — Retention, Deletion & Storage Backends

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.

Storage backends Partial

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 implementedStorageService 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.

Retention & deletion Partial

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.

8. Data-Channel Hardening (Plots, Tables & Downloads) Implemented

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).

⚠️ Honest residuals

  • The salted ID is the only capability for GET /plots/{id} and GET /tables/{id} — there is no separate per-tenant ACL on those two endpoints (workspace.py:89-92).
  • A narrow parent-directory-swap race is acknowledged as not fully closed without a read-only mount namespace (main.py:2732-2733); this is defense-in-depth. O_NOFOLLOW falls back to 0 on platforms lacking it.

9. Encryption Posture Deployment-dependent

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.

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.

10. Shared Responsibility

Because there is no vendor SaaS, the security model is a partnership between what the framework provides and what you operate.

The framework providesYou 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.

Current Limitations & Roadmap

So this page stays credible, here is what is not done yet — each tied to the same source the claims above are:

  • SSO / OIDC is stubbed, not shipped. The 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.
  • No instant access-token kill. Refresh tokens are revocable (single-use rotation, logout, and a revoked_tokens denylist), but access tokens are stateless and stay valid until their short TTL expires (≤ 1h). A token_version claim would close this.
  • Tenant isolation is code-enforced, not DB-enforced. org_id lives in plaintext sidecars and an SQLite store with no row-level ACLs; correctness depends on routes mounting the access dependency.
  • S3 backend is declared but unimplemented. Only the local filesystem path is wired (api/storage.py).
  • Retention does not delete on-disk data. data_retention_days purges only Redis job records; dataset/model deletion is on-demand (api/worker.py:1268-1292).
  • The sandbox needs an image. The container tier and hosted profile are inert until the kernel image and a runtime are deployed; subprocess and inprocess are not sandboxes.
  • Audit is tamper-evident, not tamper-proof (the sink is installed by the API app at boot, but the chain is local); the off-host durable shipper is documented but not bundled.
  • SOC 2: readiness, not certified. The control set here is built toward SOC 2-style expectations (access control, audit, isolation), but the platform is not SOC 2 certified and no audit has been performed. Treat this as readiness evidence, not an attestation.
  • Minor timing residual on the unknown-user login branch (section 6).

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.

Questions From Your Security Review?

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.