Connecting a Self-Hosted Runtime to the Prismor Platform
Prismor is open-core. The runtime ("Prismor"), the framework adapters, and the
YAML rule format in this repo are open and auditable. The control plane
(prismor-web — the org dashboard, the policy-signing private key, and the
premium feed) is proprietary. This document describes the open client side of
that link: how an enrolled Prismor install talks to the control plane, what it
trusts, and exactly what does and does not leave the machine.
See also: docs/sdk-integration.md (the runtime, not the SDK, is what connects).
Everything here is opt-in. On a plain install, prismor/runtime/enterprise/* is the
client side and a guarded no-op: is_enrolled() returns False and every
control-plane path short-circuits. Local protection — the 63 default rules in
prismor/runtime/default_policy.yaml — is always on, enrolled or not.
Connection lifecycle at a glance
The endpoints above are what the client calls; the server implementation lives in the proprietary control plane.
1. Enroll (token → device key)
prismor enroll <TOKEN>
prismor enroll --token <TOKEN> --label "ci-runner-3" --api-base https://prismor.dev
- Token: a one-time enrollment token from the dashboard (Admin → Devices → Enroll), exchanged exactly once.
- What happens:
identity.enroll()POSTs{token, label, platform, prismor_version}to/api/devices/enroll; the server returnsdevice_id,org_id,user_id,device_key,org_name. - Where the key is stored:
save_identity()writes~/.prismor/identity.json(override via$PRISMOR_HOME) at 0600, dir 0700. Thedevice_keyis a long-lived, revocable bearer credential for telemetry + signed-policy pull; it cannot push or sign anything, and it is separate from the scan API key and from cloaked secrets so revoking a laptop never breaks CI scans. - After enroll, policy is fetched immediately so admin policy applies on the first tool call.
Base URL: $PRISMOR_API_BASE (default https://prismor.dev), persisted
per-device as api_base so self-hosted/staging deployments repoint without a
rebuild.
Deviceless (SDK / deployed) agents: $PRISMOR_AGENT_KEY
A deployed agent — a container, a serverless worker — has no machine to enroll. Instead an org admin mints an agent key in the dashboard (Connections → “SDK & deployed agents”, or Devices → Service identities) and wires it into the deployment:
PRISMOR_AGENT_KEY=prism_agent_… # required — the minted key
PRISMOR_AGENT_LABEL="checkout-bot" # optional — display label
PRISMOR_API_BASE=… # optional — self-hosted control plane
The runtime treats the env key as a full identity: same bearer credential,
same telemetry upload, signed-policy pull and revocation semantics as a
device key. It is never written to disk, and it takes precedence over a
baked-in identity.json so one image can serve many workloads. Server-side
it is a service identity — listed, drilled into and revoked exactly like a
device. Headless prismor enroll <token> inside the workload remains
equivalent if you prefer token exchange over a long-lived key.
2. Signed remote policy: verify-before-apply, fail-closed
On the hot path, remote_policy.check_and_refresh() (debounced, clamped to
[5s, 600s]):
- Version probe:
GET /api/policy/versionwithAuthorization: Bearer <device_key>; a change in version/profile/capture/managed-repos triggers a pull. - Pull:
GET /api/policy/resolve→{yaml, signature}. - Verify: a detached Ed25519 signature checked with
openssl pkeyutl -verifyagainst the bundled trust rootkeys/public.pub. The private key is never in this repo. - Fail-closed: an unsigned/tampered/unverifiable policy is ignored and the last verified policy keeps applying — a compromised control plane cannot inject rules.
- Tighten-only floor: the engine enforces
_NON_OVERRIDABLE_RULE_IDS, so even a valid signed policy can never disable the destructive-command / secret-exfil protections or turn Prismor off. - Offline-safe: unreachable control plane ⇒ cached policy persists.
3. Redacted telemetry (what leaves the box)
Telemetry flows only for org-managed workspaces (workspace_scope.py).
Personal repos emit nothing — but the local security floor still applies to them;
"personal" removes org visibility and the org overlay, never protection.
Scope is decided by the git remote matching an org-claimed pattern
(settings.managed_repo_patterns). That is a developer-privacy convenience,
not a security boundary: a developer can rewrite or drop origin so a repo no
longer matches, then mark it personal. Orgs that need every workspace on an
enrolled device governed set settings.allow_personal_workspaces: false in the
signed policy — prismor workspace personal, PRISMOR_WORKSPACE_SCOPE=personal
and the non-claimed-repo default are then all ignored.
The prismor sink (prismor/runtime/sinks.py) builds
records via telemetry.build_record() and runs assert_redacted() before upload
— a fail-closed guard.
- Redacted mode (default): metadata + enums + hashes only — severity,
category, rule_id, event type, agent, verdict, a static rule title
(paths/hosts/URLs/secrets stripped), a 16-char
evidence_hash, tool_name, managed-repo context, policy_scope, the applied org policy version + profile id, device_id, session_id, subject (principal ids), and the deployment labelsenvironment/release(below). Never sent: commands, stdout/stderr, file paths, URLs, file contents, prompts, responses, payloads, matched evidence. - Full capture (admin opt-in, per-org): additionally ships evidence/content,
still scrubbed through the cloaking secret patterns so registered secrets never
leave. A flip is surfaced to the developer via a stderr
NOTICEand inprismor enroll-status(capture: FULL).
Environment and release labels
Every record (and every generic sink event) carries environment and
release, so the console can filter events and sessions by deployment and
show which release started misbehaving. Set them in the agent's environment:
PRISMOR_ENVIRONMENT=prod # lowercase [a-z0-9_-], up to 40 chars
PRISMOR_RELEASE=9f2c1ab # git sha or app version: printable, no whitespace, up to 64 chars
Values that fail validation are dropped (sent as null), never rewritten. Both
are operator config, not captured content, so they ship in redacted mode. The
OTel sink also sets them as the resource attributes
deployment.environment.name and service.version.
Upload: POST /api/telemetry/ingest. An offline spool gives at-least-once
delivery, written after redaction. Short hot-path timeout (~6s) ⇒ a slow
control plane spools, never blocks a tool call. The destination is pinned to
the enrolled api_base — a local policy cannot redirect the device-key-bearing
upload elsewhere.
4. Heartbeat
heartbeat counts inspected tool calls locally and uploads one agent_activity
record per ≤60s carrying only a count + agent/session enums — volume, not
detail — gated to managed workspaces. Powers the "tool calls inspected" KPI.
5. Revocation + backoff
Any control-plane 401/403 ⇒ mark_revoked(); for 1h, control-plane calls
short-circuit (no hammering). Local protection is unaffected — the last good
signed policy keeps enforcing. prismor logout wipes identity, cached policy,
spool, heartbeat, and scope map.
Trust model
- Public-key verify only. The device holds no signing key; it only verifies
org policy against
keys/public.pub. The signing key lives in the control plane. - No secret leaves. Cloaked secrets and the scan API key are never sent; even full capture scrubs secret-shaped values. The device key is scoped, revocable, stored 0600.
- Admin CAN see (managed repos only): redacted findings, per-user subject ids, repo identifiers, tool-call volume; with opt-in, scrubbed raw content.
- Admin CANNOT see: personal-repo activity, raw secrets, or — without opt-in — any commands, paths, URLs, prompts, or file contents; and cannot silently disable the local security floor.
Known follow-ups for this path: auth on the enroll UX, and subject handling.