Plugin Security & Permissions
Plugin Security & Permissions
Plugins extend Ciaren with real Python code. Ciaren's job is to make that explicit and consensual: you see what a plugin wants, you approve it, and you can verify where it came from. It is a trust/consent boundary, not an OS sandbox — read the local-first trust model for what that does and doesn't guarantee.
Only install plugins you trust
A plugin is ordinary Python that runs on your machine with your account's access and is not sandboxed. A malicious or buggy plugin can read or delete your files, use your saved credentials, run other programs, or send data over the network — and the permission list is a heads-up, not an enforced limit. Install only plugins from sources you trust and whose code you can review (prefer signed packages from a trusted key). Ciaren cannot vet third-party plugins and is not responsible for what they do once you install and approve them. You install plugins at your own risk.
Permissions
A plugin declares the permissions it needs in its manifest:
{ "permissions": ["network", "credentials", "filesystem_read"] }
Recognised permissions include: filesystem_read, filesystem_write, network,
credentials, subprocess, shell, docker, local_model_load, joblib_load,
database_access, cloud_access, llm_access, telemetry.
Enforced permissions for model loading
Most permissions are disclosure — surfaced before you approve, not enforced at runtime (see the honest-boundary note above). Two are actively enforced by the host's plugin ModelStore, because deserializing a model executes pickled code:
local_model_load(orjoblib_load) — required for a plugin to load a model from an MLflowruns://models:/URI;joblib_load— required for a plugin to load a local.joblibfile, which must additionally live inside the server's artifact directory (path traversal is refused). Bare.pkl/.picklefiles are always refused.
Persisting a model (training) needs no grant — it writes to the server-managed MLflow store, the same place core train nodes log to.
Opt-in runtime enforcement
By default the other permissions are disclosure-only. You can turn on a second,
opt-in layer that actively checks a plugin's granted permissions against its
own code while a plugin node runs, using a CPython audit hook. Set
CIAREN_PLUGIN_PERMISSION_ENFORCEMENT:
| Mode | Behaviour |
|---|---|
off (default) | No hook installed, zero overhead. Permissions stay advisory. |
warn | Logs when a plugin performs a network / filesystem_write / subprocess / shell action it wasn't granted — an audit trail; nothing is blocked. |
enforce | Additionally raises PermissionError, so the ungranted action fails and the node reports an error. |
Turn it on for shared or less-trusted setups where you run third-party plugins and want a real signal (or block) when one reaches beyond what it declared.
Still not a sandbox
This raises the bar and gives you an audit trail — it does not contain a
determined plugin. A plugin can still escape the check via a thread it spawns, a
child process, or native code, and filesystem reads are never blocked (the
import system and pandas open files constantly). For untrusted code that needs
network access, use OS/network-level egress control; for sensitive IP, keep the
logic in a remote service (see thin-client plugins). The
enforcement mode in effect is reported as permission_enforcement in
GET /api/plugins/diagnostics.
Plugin connectors
A plugin connector's runtime (test / list / read / write) only exists once the
plugin is approved — a gated plugin's connectors appear nowhere. The host also
applies its SSRF guard to the
connection's host field before invoking a plugin runtime, and connection
secrets keep the reference-only rule (env: / keyring: / file:, see
connection security model): the resolved
value is passed into a single call and never stored. The same
plaintext-credential guard the core REST connector uses also applies to plugin
connectors — a custom header or query parameter shaped like a credential
(authorization, api_key, token, …) is refused at save time rather than
persisted in plain text.
Approval gating
For drop-in plugins (those discovered from a plugin directory with a
ciaren-plugin.json):
- A plugin that declares permissions starts pending — its entry point is never imported — until you grant those permissions.
- You approve via the API or UI; the registry rebuilds and the plugin loads live.
- Revoking a required permission sends it back to pending (its code stops loading).
- Any plugin can be disabled; a disabled plugin is never loaded.
- In
processexecution mode, already-spawned workers pick up a revocation on their next task, not only once the pool is recycled — each submitted task carries the current permission generation, and a worker behind it re-reads the saved state and rebuilds its registry before running anything.
Entry-point packages (ones you deliberately pip install) load without this gate —
installing the package was the consent step.
Managing permissions
| Surface | How |
|---|---|
| UI | The Plugins page shows each plugin's status; clicking a plugin opens its details with requested vs. granted permissions and the Approve / Revoke / Enable / Disable actions. A loaded plugin shows its permissions as active; revoking the ones you granted sends it back to pending. Status and signature badges explain themselves on hover. |
| API | POST /api/plugins/{id}/enable|disable|grant|revoke — see Catalog & Plugins API. |
| CLI | ciaren-plugin enable|disable <id> — see Plugin CLI reference. |
State (enabled + granted permissions) persists in plugin_state.json under the
data dir (override with CIAREN_PLUGIN_STATE_FILE).
Signature verification
Plugins distribute as signed .ciarenplugin
packages. Ciaren verifies a detached Ed25519 signature against your trusted
keys before installing:
| Outcome | Meaning | Installable |
|---|---|---|
trusted | Valid signature from a key you trust | ✅ |
untrusted | Valid signature, key not in your trusted set | ✅ (warned) |
unsigned | No signature (typical for community plugins) | ✅ (warned) |
invalid | Digest mismatch or bad signature | ❌ always refused |
Require a trusted signature with ciaren-plugin install pkg.ciarenplugin --trusted.
Trusted keys come from three sources: keys pinned into the app itself (the
official marketplace publisher keys — these verify as trusted out of the box and
can never be overridden by configuration), plus your own additions via
CIAREN_TRUSTED_PLUGIN_KEYS and ~/.ciaren/trusted_keys.json.
Signing/verifying needs ciaren[signing].
What signatures protect against: tampered packages, swapped downloads, and unofficial builds presented as official. What they don't: a signed-but-buggy plugin, or someone copying already-installed files.
The signature covers the package digest and the signer metadata (key_id,
publisher, algorithm), and trust is matched strictly by key_id — a
package-supplied publisher name can never select which trusted key is checked.
So a validly-signed package can't be relabelled to impersonate a trusted key.
Reinstalls re-gate on identity change (TOFU)
Plugin ids are claimable, so approval is pinned to the signer, not the id:
Ciaren records which key signed a plugin at install time, and a reinstall that is
signed by a different key, arrives unsigned where the previous install was
signed, or drops from trusted sends the plugin back to pending — its new
code stays un-imported until you approve the new publisher. A normal update
(same key) keeps your approval.
Marketplace trust badges are earned, not claimed
The trust tier shown for an Explore catalog entry is derived by verifying
the artifact's signature against your trusted keys — a trust value written
into the index or manifest by the publisher is ignored. Likewise, a catalog
entry without a digest is refused at install rather than skipped: the digest
is what binds the entry to the artifact bytes.
Install-time hardening
Installation extracts a .ciarenplugin defensively: entry names are validated
lexically (absolute paths, .., and \/drive-qualified paths are rejected) and
again after path resolution, symlink entries are refused, and per-entry/total
uncompressed size and entry-count caps bound a decompression bomb. Plugin ids that
aren't filesystem-injective (anything outside [A-Za-z0-9._-]) are rejected rather
than silently rewritten, so one plugin can't clobber another's install directory.
Post-install tamper detection
For a packaged (.ciarenplugin) install, Ciaren also pins a SHA-256 digest of the
installed ciaren-plugin.json at install time. Every time the plugin registry
rebuilds, the loader recomputes that digest and refuses to import the plugin's
code if the on-disk manifest no longer matches — catching a hand edit that, say,
strips license_required or widens permissions after installation. A missing
baseline (source/dev-dir installs, entry-point plugins) or a read error skips the
check rather than blocking startup. This is defense in depth, not a sandbox
boundary: the pinned baseline lives in the same user-writable state file
(plugin_state.json), so it deters casual tampering, not a determined local
attacker with file access.
Dangerous capabilities
Some operations execute code or touch credentials. Ciaren already enforces:
- Pickle model files are refused — loading a pickle runs arbitrary code.
.jobliband.jsonartifacts may load directly, but only from inside the artifact root (anything else must be referenced via an MLflowruns://models:/URI instead)..joblibisn't actually format-safe — it serializes with pickle under the hood — so that path's only real protection is the artifact-root confinement, not the file extension;.json(XGBoost's native format) is the one that's genuinely code-free. See Local-first trust model andapp/ml/security.py. - Connection secrets are referenced, not stored — passwords live in environment
variables (
password_env); they never enter the flow graph,.flowfiles, or exported code. - Custom SQL / Python runs with local-process privileges — fine for local use; for shared environments add read-only connections and audit logging (roadmap).
Thin-client plugins
Because an installed plugin's code runs on the user's machine, its logic can be
read (a .ciarenplugin is a zip; compiled .pyc only deters casual inspection)
and its local license check can be patched out. The robust pattern for paid or
sensitive plugins is therefore a thin client: keep the valuable logic on your
own server, and ship a small plugin that calls it. A node receives its plugin's
own signed license token via NodeContext.license_token and forwards it with each
request; your server validates the token (signature, expiry, revocation, quota)
and does the work — the enforcement lives where the user cannot patch it. See the
API reference and
Packaging & Distribution for the full
pattern.
Known limitations
Ciaren is honest about what the plugin system does and doesn't guarantee:
- Not a sandbox. An enabled plugin runs unsandboxed Python with your full account access. Permissions are a disclosure/consent boundary; only model-load is enforced by the host, and the opt-in runtime enforcement above is a bar-raiser, not containment.
- You are the reviewer. Ciaren cannot vet third-party plugin code. Signatures
prove who published a package and that it wasn't altered — not that it is safe.
Install only plugins whose source you trust and can inspect (
.ciarenpluginfiles are plain zips; unzip and read them, or read the installed files under~/.ciaren/plugins/<id>). - Compatibility is checked, safety is not. Install refuses a package whose
declared
ciaren/api_versionis incompatible with this build (before it can replace a working install), and extraction is hardened against zip-slip / symlink / zip-bomb packages — but none of that judges what approved code does. - Local licensing is soft. A cached license token can't be forged (it's signed) but the local gate can be bypassed by editing the code that runs on your machine. Treat local licensing as UX; put real entitlement/quota enforcement server-side.
- Bytecode is not protection.
--compile(ship.pyc) deters casual reading only; bytecode decompiles.
When in doubt, don't approve a plugin — a gated plugin's code never runs.
Recommendations
- Prefer signed plugins from trusted publishers; use
--trustedin shared setups. - Review a plugin's requested permissions and its code before approving.
- In shared/less-trusted deployments, set
CIAREN_PLUGIN_PERMISSION_ENFORCEMENT=warn(orenforce) and, for plugins that need outbound access, add network-level egress control. - Keep
cryptographyinstalled (ciaren[signing]) so signatures are verified rather than skipped. - Run a dependency license/vulnerability scan before distributing builds (the repo ships CodeQL + dependency-audit CI workflows).