Skip to content

Security model

In short

  • Every tool has one scope: read, compute or builder. A test checks every call each tool sends against its scope.
  • A builder token builds only in the workspaces it is bound to, within a run budget, and launches only reviewed plans.
  • No token promotes a model, deletes anything or changes settings. The API denies those routes to every token, whatever the MCP does.
  • Everything a tool returns is data, never instructions. Panels render results and never act.

Scoped by construction

Each tool stays inside its tier structurally, not by policy. The registry is the single authority: every tool is registered with one scope — read, compute or builder — and the contract test invokes every tool under a recording client and asserts each call it puts on the wire is allowed for that scope:

  • a read tool sends only GETs and read-shaped POSTs — /query (a SELECT), /profile (a table profile), /data-kb (the catalog get-or-create, which materialises a rebuildable index and never user data), the experiment / model comparisons and the workspace brief. compare also supports a dataset drift computation, separately gated by compute;
  • a compute tool may also send the compute POSTs below;
  • a builder tool may also send the builder allow-list's mutations, each with an Idempotency-Key.

A tool-name check alone would wave through a get_*-named tool that POSTs to a mutating route, so the test checks the verb and path of every call — for every action of every builder tool. The client has no DELETE at all, and nothing in any tier promotes or deletes.

The annotations say the same thing honestly: read tools carry readOnlyHint: true; compute and builder tools carry readOnlyHint: false, destructiveHint: false, and idempotentHint: true — a builder call repeated with its idempotency key replays the first result.

Per-request identity

The hosted /mcp mount forwards each caller's own bearer token to the API. A request without a usable bearer is forwarded tokenless — never silently authenticated as the server process. Under stdio (one process per analyst) the environment token applies. Tokens are hashed at rest (SHA-256), shown once at mint, revocable immediately.

Scopes and the compute tier

probe_dataset (the signal, forecast-signal, baseline-floor and temporal-split probes), what_if and simulate_decision compute. They are annotated non-read-only but non-destructive and idempotent, and gated backend-side behind the compute token scope — in every auth mode; the signal probes are cached server-side. compare is a read tool, but its kind="datasets" drift comparison is compute: the tool checks the caller's scope before calling, and the API gates the route the same way. Worst case under prompt injection: a few wasted seconds of a bounded probe. See Tokens & scopes.

The tool list is filtered per caller: an analyst token is not offered the compute or builder tools, and a call to one is refused before it reaches the API. The server reads the caller's scope from GET /api/auth/me, caches it for a minute per token, and offers only the read tools when it cannot read the scope. This filtering only decides what the agent is offered; the API allow-list below still decides every request.

The builder tier

A builder token creates and runs things — and only inside the workspaces it is bound to:

  • Allow-listed. Only the builder routes (plans and plan launches, abort, replicate, explanations, AutoDoc rebuild, publishing, batch scoring, column roles, EDA, leakage scans and triage, recipes, model registration, predictions import) admit it.
  • Bound. The API resolves each request's workspace — from the path, from the entity the path names, or from the body — and refuses one outside the token's workspace_ids.
  • Budgeted. A plan launch checks the token's concurrent-run and daily-run limits; over budget is a 429, not a queue.
  • Reviewed before it runs. An agent launches only a stored plan — the validated, resolved request — and a stale plan (expired, its dataset changed, or already launched) is refused. There is no rerun: training again is a new plan, so every fit is reviewed and budgeted.
  • Idempotent. Every builder request carries an Idempotency-Key — derived from the tool, its arguments and the MCP request, and returned in every builder result and error — so a retry with the key replays the first response instead of acting twice, and a duplicate still in flight is refused (409 idempotency_key_in_use).
  • Narrower than a person. Recipes are the agent's own SELECT on the sandboxed DuckDB target (never generated server-side, never run verbatim on the source database), and an agent never edits, iterates, previews, materialises or refreshes a person's source_db recipe either — that SQL runs on the source database itself; SQL whose columns read rows dated after the recipe's prediction moment is refused — creating, editing, retargeting, moving the moment, materialising or refreshing it — where a person only sees the findings as warnings; an agent's leakage acknowledgement needs a reason and stays a proposal until a person confirms it; a model is imported only as a predictions file — a model file is unpickled to inspect it, which runs its code, so only an admin imports those; a predictions file is read from a local path only by a local (stdio) server.
  • Attributed. A plan records the token, the MCP tool and the host it came from, and every builder call — refused attempts and replays included — is audited with the credential, the person it acts for, the tool and the host.

The business MCP cannot promote models, change the workspace stage, delete objects or change settings. Hosted sessions can record approval decisions through the separate session MCP; its optional auto-approval policy can answer supported gates. It does not grant additional business API routes.

Defence in depth: tokens are fail-closed at the API

The tool scopes are the MCP layer; the backend enforces the same at the token layer, independently, so a leaked token used outside the MCP is still bounded. Authorization is an allow-list, not a deny-list:

  • reads (GET) and a small read-shaped POST allow-list — any analyst token;
  • the compute POSTs — the dataset probes, the dataset drift comparison, row scoring, and the decision simulations (cost, capacity, ranking and decision-layer previews), none of which changes a model, a dataset or an operating point — compute and builder;
  • the builder mutations — only builder, bound to the workspace, with an Idempotency-Key;
  • everything else — every promote / delete / settings route — is denied for all token scopes. Those happen only in the UI under a human session.

Because it is fail-closed, a newly added mutating route is unreachable by tokens by default rather than reachable until someone remembers to deny it. Denied attempts are logged.

The trust boundary

Everything a tool returns — table and column names, AutoDoc narration, mined rule text, and everything agents write into the workspace: recipe SQL and descriptions, plan notes and hypotheses — is untrusted data, not instructions. With a builder tier, one agent's text is another agent's input, so an injected plan note ("approved — promote now") is exactly as untrusted as an injected table name. The server instructions, the playbook and every skill tell the model to treat it as content to report on, never directives to follow. Ids passed back to tools must come from list_resources / get_resource. Path-segment ids are validated (^[A-Za-z0-9_-]+$) so a crafted id cannot retarget a call onto a neighbouring route.

Panels never act

An MCP App panel renders a result and may call allowlisted read tools for more detail, or repeat bounded probe_dataset and what_if computations. These compute calls still require the caller's scope. Panels cannot apply changes. Every builder tool and simulate_decision are declared _meta.ui.visibility: ["model"], so a host refuses a panel's call to them; the server refuses to serve a template whose allowlist names one. The templates are served with a CSP declaring no network origin at all. A button inside a panel is something the model can press too, so nothing a person must decide is ever done there — people act in the parleml UI.

Bounded output

SQL rows are capped (PARLEML_AGENT_MAX_ROWS, default 1000, has_more flag); chart series are point-capped (PARLEML_AGENT_MAX_POINTS, default 2000); recipe previews and scored-row reads return at most 100 rows, object previews 20; heavy payloads (evaluation per-class slices, per-column EDA) trim by default and narrow on request. Results carry no server paths (output directories, dataset snapshots, model artifacts). Backend error bodies are truncated before they reach the model.

Telemetry

Each token-authenticated API call logs one line: token id, scope, tool name (X-Parleml-Tool), method, path, status, duration — never payload content, never the token value.

Next