Skip to content

Extension Access Contract

“Who can ACCESS / USE this installed extension” — one model across all five extension kinds (agent / connector / skill / artifact / workflow). This is the end-user access axis, distinct from the host-port capability axis (requestedHostPorts, the capabilities an admin approves at install — see extension-permissions.md). The runtime installer consumes the API documented here.

The contract guarantees one thing: every installed extension, of every kind, has exactly one authoritative place that answers “may this actor list / read / use / execute / share / manage this extension”, resolved against a canonical resource identity that no caller constructs by hand.

TableKeyHolds
installed_extensionid (+ identity (organization_id, owner_level, owner_id, package_name))the install row; kind ∈ {agent, connector, artifact, skill, workflow}
extension_access_policy(resource_kind, resource_id)the AgentAuthPolicy (list/data/execute visibility + allowRunSharing) + installed_by_user_id
extension_co_owners(resource_kind, resource_id, user_id)per-principal co-owner grants

resource_kind covers all seven polymorphic access kinds: agent_run, agent_template, skill_package, skill, connector, artifact, workflow.

Canonical resource identity. For connector / artifact / workflow, the polymorphic resource_id IS the installed_extension.id. Org scoping comes free from the row’s organization_id, and teardown is per-row. Resolve identity through @cinatra-ai/extensions/extension-resource-identity (resolveConnectorResource, resolveInstalledExtensionResource) — never build a resource_id by hand. This is the load-bearing guarantee of the contract: one identity per installed extension, derived the same way everywhere.

All from @cinatra-ai/extensions:

  • Install timesetExtensionInstallAccess({ kind, resourceId, policy?, coOwnerUserIds?, installedByUserId }) (/install-access-contract). Zod-validates the policy; writes policy + installer + co-owners in ONE transaction; applies the per-kind default when no policy is given (connector / artifact / workflow default to workspace; agent and skill kinds default to owner and their existing install flows pass an explicit policy). The installer chooses the access tier at install time.
  • Enforce (async, throws)enforceExtensionAccess({ kind, resourceId, owner }, actor, op) (/enforce-extension-access). op ∈ {list, read, use, execute, share, manage}.
  • Enforce (async, predicate)canExtensionAccess(...){ allowed }.
  • Pure decision (no I/O)evaluateExtensionAccess({ policy, coOwnerUserIds, installedByUserId, owner, actor, op }).
  • TeardowndeleteExtensionPermissions(kind, resourceId). Called automatically by transitionExtensionLifecycle on hard removal (uninstall / force_delete / purge / registry_remove) for connector / artifact / workflow; archive preserves the rows.

Ownership tiers and the owner-aware admin tier

Section titled “Ownership tiers and the owner-aware admin tier”

Access policy visibility resolves over the ownership tiers user → team → organization → workspace, plus an owner-aware admin tier. enforceExtensionAccess’s "admin" visibility means platform_admin OR org_owner / org_admin of the OWNING org (for org-owned extensions) — not platform-admin-only. This deliberately diverges from the agent run path (packages/agents/src/auth-policy.ts policyAllows, where "admin" = platform admin only), which is left unchanged. The divergence preserves the connector rule that visibility="admin" connectors are usable by org admins.

manage resolves to platform/org admin OR installer OR co-owner. Connectors keep a stricter shim: enforceConnectorPolicy("manage") stays org-admin-only.

The shape is one across all kinds; each kind plugs its install, enforce, discovery-filter, and teardown into the same contract.

KindInstallEnforce (use)Discovery filterTeardown
agentimport flowenforceRunAccess (run path)existingdeleteAgentTemplate
skill / skill_packageinstall flow + setExtensionInstallAccesscanEditExtension / policy gatesskills permission surfaceuninstallSkillPackage
connectorsetExtensionInstallAccess (dev seed + installer)enforceConnectorPolicyenforceExtensionAccess (canonical-first, with an absence-only connector fallback)isConnectorVisibleToActor (delegates)transitionExtensionLifecycle
artifactsetExtensionInstallAccessauthorArtifact emit gate (canAccessArtifactExtension, op execute)searchArtifactExtensions / getArtifactExtension (op list / read)transitionExtensionLifecycle
workflowsetExtensionInstallAccessworkflow_template_instantiate (op execute) via host-injected assertExtensionAccessworkflow_template_list / _get (op list / read)transitionExtensionLifecycle

Workflow dependency-direction note: packages/workflows must NOT import @cinatra-ai/extensions (the dependency runs the other way). The access check is a host-injected dep — WorkflowHandlerDeps.assertExtensionAccess, built in src/lib/workflow-host-deps.ts. Only EXTENSION-ORIGIN templates (those whose origin.package names an installed workflow extension) are gated; operator- authored templates keep their existing row-scope checks.

Governance scope: only install-tracked extensions are governed. A disk-registered dev artifact or operator-authored workflow with no installed_extension row is ungoverned (allowed). A DB read error fails CLOSED.

Any surface that exposes an installed extension MUST:

  1. At install — call setExtensionInstallAccess with the installer’s chosen access (or the per-kind default). This is the ONLY sanctioned install-time access write path; do not write a kind-specific default directly.
  2. At render / dispatch / use — gate through enforceExtensionAccess / canExtensionAccess (or the per-kind delegating shim) using the canonical resource_id from extension-resource-identity.
  3. At teardown — rely on transitionExtensionLifecycle (which calls deleteExtensionPermissions); do not hand-delete access rows.

See also: developer guide index · Extension permissions · Extension lifecycle and distribution · Extension data ownership