Security model¶
In short
- Every tool has one scope:
read,computeorbuilder. A test checks every call each tool sends against its scope. - A
buildertoken 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.comparealso supports a dataset drift computation, separately gated bycompute; - 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_dbrecipe 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 — anyanalysttoken; - 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 —
computeandbuilder; - the builder mutations — only
builder, bound to the workspace, with anIdempotency-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¶
- Tokens & scopes: create and revoke tokens.
- Operations & security: what hosted sessions can reach.