Features and access
Feature grants, role defaults, and the deny-by-default visibility predicate.
Features are the access currency of the package system. A package declares the capability classes it provides; project roles receive grants of those feature ids; and every surface a caller can see or invoke — tools, skills, routes, widgets, dock entries, commands, workflow templates — is checked against the caller's granted features through one shared predicate. There is no second authorization vocabulary: if something needs gating, it names a feature.
Declaring features
Features live in the manifest's requires block:
"requires": {
"providesFeatures": [
"my-pkg.read",
{ "id": "my-pkg.admin", "title": "Administration", "description": "Manage package settings." }
],
"defaultRoleGrants": {
"owner": ["my-pkg.read", "my-pkg.admin"],
"admin": ["my-pkg.read", "my-pkg.admin"],
"member": ["my-pkg.read"],
"viewer": []
}
}providesFeaturesentries are either a bare id string or a{ id, title?, description? }object. The object form adds human-facing metadata for the admin roles UI without changing the id. Duplicate ids are rejected at manifest validation.- The id prefix is the tier. Feature ids are two-tier and the tier is the
prefix, so the boundary can never drift out of sync with a hand-kept list.
platform.*marks a decision that crosses the project boundary — another tenant, a platform-global file, cross-user records, the global credential scope. Every other id,project.*included, acts inside the caller's ownsession.projectId. The tier is a label, never a gate:hasFeatureremains the one access predicate and has no tier branch. What the label governs is defaults — aplatform.*id carries no default role grant and reaches nobody until an operator grants it deliberately. Name a capabilityplatform.*only when it genuinely reaches outside the project. defaultRoleGrantsmaps role names to the feature ids that role receives by default when the package is installed; admins can adjust grants per project afterwards. It deliberately cannot reach the strongest roles. For any package that is not a host builtin, the merge skips every role holding the'*'wildcard and every role at or stronger than theadminpriority anchor — so anowneroradminentry in a project-dropped or third-party manifest is silently ignored, and those ids stay grantable only by hand in Admin → Roles. Declare grants formanager,member,viewerand your own custom roles. Enumeratingowneris dead weight in every case: the seeded owner role holds the wildcard already.- The list of consumers — which tools, skills, commands, and App surfaces a feature actually gates — is never stored on the manifest. It is derived from the loaded definitions at boot, so it can never drift from the code.
The one visibility predicate
Every requires: { features: [...] } declaration is evaluated by a single
shared function, meetsRequires(session, requires), built on hasFeature.
Surfaces never inline their own copy of the logic.
| Declaration | Result |
|---|---|
No requires / empty features | Visible to everyone (ungated) |
Caller's grantedFeatures contains '*' | Passes every check (the '*' wildcard; the seeded owner role holds it, admin does not) |
| Multiple features listed | Every listed feature must be granted (AND semantics) |
| Feature id that no package provides | Never granted to any role except '*' holders. It cannot appear in a derived grant list either, so it is wildcard-holder-only by construction |
Caller has no grantedFeatures at all | Denied |
A failing check hides the surface entirely. A gated skill is not injected into the system prompt, does not appear in the packages overview or any catalog, and is never rendered as a locked or disabled entry — a caller who lacks the feature cannot infer that it exists. The same applies to gated tools (model-facing catalog and dispatch), commands, widgets, and dock entries.
Visibility and execution are gated independently. Hiding happens at the
advertisement boundary; execution is additionally enforced server-side — a
route's declared feature is checked by the dispatcher before the handler
runs (see Routes), tool dispatch rejects
callers that fail requires.features, and filesystem access goes through
URI policies.
Where requires appears
| Surface | Field | Notes |
|---|---|---|
Markdown contributions (PackageFile) | requires.features | Authored via the flat requiredFeatures: [a, b] frontmatter key — see Contributions |
Tools (PackageTool) | requires.features, requires.escalations | features gates catalog visibility and dispatch; escalations is catalog metadata for input-dependent checks the handler performs itself |
Commands (PackageCommand) | requires.features | Same semantics |
Workflow templates (PackageWorkflow) | requires.features | Gates every template-listing surface — see Workflow templates |
| Widgets and dock entries | requires.features | Gated surfaces are omitted from the workspace snapshot — see App surfaces. A widget may additionally list requires.tools, which is catalog metadata, not a gate |
| Routes | export const feature = '...' | One feature per route file, enforced by the dispatcher before the handler runs |
| Whole package | requires.accessFeature | A single feature id that gates the ENTIRE package — see below |
For imperative checks inside a handler, the same building blocks are exported
from @neuralis/package-system/access:
import { hasFeature, requireFeature, meetsRequires } from '@neuralis/package-system/access';
hasFeature(session, 'my-pkg.read'); // boolean
requireFeature(session, 'my-pkg.admin'); // throws a 403-status error when missing
meetsRequires(session, { features: ['a', 'b'] }); // AND over the listBase-access feature — gating a whole package
Individual tools, skills, and widgets can each name a feature, but a package can also gate its entire surface with one declaration:
"requires": { "accessFeature": "labs.access" }A caller who lacks labs.access sees none of the package — no tools, no skills,
no widgets, and no entry in the agent's <packages> overview. It is hidden, not
shown locked, so its existence can't be inferred. Leaving accessFeature out
keeps the package visible to everyone — so packages imported from other
ecosystems load unchanged.
This is the clean way to build and test a package in isolation: declare an
access feature with no default role grant — until you grant it to a role, only a
'*' holder (owner-strength) sees the package. A project admin does not,
because the seeded admin role holds an enumerated list rather than the
wildcard. Grant it in Admin → Roles to the roles that should test it.
A declared access feature is grantable in Admin → Roles
— so once you've gated a package, you can grant base access to exactly the roles
(or people) that should have it. An administrator can also attach a required
feature to an already-installed package from the workspace Packages tab — picking
from the project's existing features — a restrict-only override that can
tighten visibility but never grant access, so it can never weaken a floor. Such an
override-attached feature is also grantable in Admin → Roles: the feature
catalog is project-aware, so it surfaces both manifest-declared access features and
per-project overrides under their package.
Scope — who a loaded package reaches
Package consumption is governed by three orthogonal axes:
| Axis | Question | Mechanism |
|---|---|---|
| uri-policy | Which paths may the package touch? | URI policies |
| feature | Which roles may use a capability? | requires (above) |
| scope | Which user/agent is a loaded package advertised to? | source scope + canAccessScope |
The scope axis matters when a capability is owned by an individual or an agent
rather than the whole project. A package contributed by a user-scoped or
agent-scoped source loads for the project but is advertised only to that user
(or that agent) — its tools, skills, and widgets never reach another member's
stream, MCP catalog, or workspace. The same one predicate, canAccessScope,
decides both file access and package visibility, evaluated at every advertise
boundary. This is what makes per-person and per-agent capabilities possible on a
shared, multi-tenant project.
Role priority
Feature grants answer "what can this caller do". Role priority answers an orthogonal question: "what strength of role may this caller hand out". A member who is allowed to create agents must still not be able to mint an admin-strength agent or invite an owner.
Priority is one ordinal number per role, lower = stronger. The five built-in roles anchor the scale and are immutable:
| Role | Priority |
|---|---|
owner | 1 |
admin | 2 |
manager | 10 |
member | 20 |
viewer | 30 |
Declared priorities are integers in 1..99. The gaps between the anchors are
deliberate: an operator can slot a custom role between two built-ins without
renumbering the scale.
Custom roles carry their own declared priority in the project's role definitions. A role name is cosmetic — anyone may invent one — but the priority number is enforced. An unknown label with no declared priority resolves to the weakest possible sentinel, so it is always assignable but can never be an escalation.
The assignment rule, implemented once in canAssignRole /
validateRoleAssignment: a caller may assign a role only when the target's
priority is equal to or weaker than their own (targetPriority >= callerPriority). A caller priority of undefined means a system/internal
session with no membership identity, which bypasses the check. The caller's
own priority travels on the canonical session as SessionContext.priority
(see Sessions).
This single predicate is enforced at every role-handling boundary: project invitations, member role changes, role definition edits, and agent provisioning from package team members.
Deny by default
The whole layer composes into one posture: undeclared means ungated,
ungranted means invisible, and unknown means denied. Referencing a feature id
that nothing provides is a legitimate authoring technique — it makes a
contribution reachable only by a '*' holder, without any extra mechanism. How roles and
feature grants are administered across projects is covered in
Roles and features.