@package-system

URI policies

Path protection for package namespaces, declared in the manifest and enforced at four layers: route, UI, tool, and vector index.

URI policies are the path-protection unit of the platform. Every file artifact lives at a URI (data://..., packages://..., brain://...), and a single evaluator decides — per caller, per URI — whether read, write, and exec are allowed. Packages ship baseline rules for their own namespace in the manifest; sources persist the merged result; and the same evaluation runs at every layer that can touch a path.

Declaring baselines in the manifest

A package declares uriPolicies under its neuralis block. Patterns must stay inside the package's own URI namespace, written with the literal <self> placeholder:

"uriPolicies": {
  "data://<self>/**": {
    "default": { "read": true, "write": true, "exec": false }
  },
  "packages://<self>/**": {
    "default": { "read": true, "write": false, "exec": false },
    "paths": [
      { "pattern": "packages://<self>/src/**", "permissions": { "read": true, "write": false } }
    ]
  }
}

Two prefixes are recognised today:

PrefixCovers
packages://<self>/The package's installed root
data://<self>/The package's per-project data directory

Anything else — a foreign package's namespace, an absolute OS path, another scheme — is rejected at manifest load time, and the package does not register. The same check applies to every pattern inside the nested paths[] array (relative patterns without a :// are allowed). Each top-level entry must declare a complete default: { read, write, exec } boolean triple. This is the single most common authoring mistake the validator prevents: a package can only pre-authorize paths it owns.

The permission shape

Every pattern maps to a hierarchical permission tree — the same ConnectorHierarchicalPermissions shape used by source connectors:

{
  default: { read: boolean, write: boolean, exec: boolean },
  byRole?:  Record<roleName, Partial<{ read, write, exec }>>,
  byUser?:  Record<userId,   Partial<{ read, write, exec }>>,
  byAgent?: Record<agentId,  Partial<{ read, write, exec }>>,
  paths?: Array<{
    pattern: string,              // glob: **, *, ?
    permissions: Partial<{ read, write, exec }>,
    roles?: string[],             // rule applies only to these roles
    agents?: string[],            // rule applies only to these agents ('$self' allowed)
    users?: string[],             // rule applies only to these requesters ('$owner' allowed) — allow rules only
    override?: boolean,           // flips deny-wins to allow-wins for this rule
    floor?: true,                 // immutable restrict-only floor — see below
  }>,
}

byRole and a rule's roles[] are both written with role names, but they no longer behave the same way, and the difference matters when you define a custom role.

byRole is resolved by role STRENGTH. Roles carry an ordinal priority (lower = stronger; the built-ins anchor the scale at owner 1 · admin 2 · manager 10 · member 20 · viewer 30), and a custom role declares its own inside 1..99. Resolution takes the first of:

  1. an entry whose name matches the caller's role exactly;
  2. otherwise, the single entry sitting at the caller's exact priority — so a custom role declared at priority 1 receives an owner-keyed override, which is the whole point of the strength scale;
  3. otherwise, every entry belonging to a role at least as strong as the caller contributes its explicit denials, and nothing else. This step can only take a capability away, never grant one — so a role weaker than viewer inherits the restrictions viewer carries instead of escaping them.

A role stronger than every listed entry is unaffected: an owner does not inherit a viewer restriction.

A rule's roles[] is still an exact name match. It selects which roles a single paths[] row applies to, and a custom role matches only if it is named there. If a custom role is meant to reach paths that a row grants to ["owner", "admin"], add its name to that row.

Evaluation resolves in a fixed order: default → byRole → byUser → byAgent → paths[], followed by a final floor pass. Path rules are deny-wins by default — a matching rule can take a capability away, and a later allow does not restore a denial. A rule with override: true applies its permissions unconditionally, including re-allowing something a previous rule denied. Use override sparingly and for the narrowest possible pattern.

A pattern is matched against the whole URI, anchored at both ends. dir/** therefore matches what is inside dir and not the node dir itself. To deny a directory and keep its name out of a listing of the parent, write both rows — dir and dir/**. Declared with only the /** half, a listing of the parent still shows the folder name and listing the folder itself resolves as allowed, returning an empty 200 rather than a 403.

Matching is done on the canonical URI, not on the caller's spelling: the evaluator collapses slash runs and resolves . / .. segments in both the pattern and the URI under test before comparing, so every way of writing one path reaches the same verdict. A trailing slash is preserved, because dir/** matches dir/ while it does not match dir.

Immutable floors

A normal path rule — even a denial — is something an owner or admin can later edit or override through the source configuration. Some paths need a protection that cannot be edited away at all: for those, a package manifest marks a rule floor: true.

A floor is deliberately narrow:

  • It may carry only false capabilities (read, write, and/or exec). A floor that tries to grant anything, or that pairs the marker with override, roles, agents, or users, is rejected at manifest load.
  • It is applied in a separate, final pass after every normal rule — so a later override: true, or an admin edit, can never re-enable a capability a matching floor took away.
  • Its provenance is trusted package manifests only. The policy editor and the source-create/update routes reject any caller-supplied floor and refuse to remove or alter an existing one (a stable 409 / immutable_policy_floor error), and a lower-trust package cannot mint one.
  • It governs the vector index as well as reads. A path whose floor denies read is never newly embedded — what cannot be read cannot be embedded — so a single declaration protects both axes and no companion sync-exclude entry is needed. Only the read capability matters there: a floor that denies just write (the shape that write-protects a runtime-owned file) leaves the file fully searchable, which is the intent.

A trusted manifest declares a floor in one of two places. uriPolicies is the usual one, and it is scoped to the package's own data:// and packages:// namespaces. A source connector declares floors on its own kind instead, in defaultPermissions.paths — that is the only channel available when the connector owns a different URI scheme entirely, since a uriPolicies key outside those two namespaces is rejected at manifest load. Connector floors are copied into a source config when the source is attached, and the boot reconcile applies them to sources that already exist. Only the floor rows cross over; the connector's ordinary per-path rows remain a display baseline for the attach form. machine-core's browser-profile and dotfile-credential denials are the shipped example — see machine sources.

Floors are how the platform keeps a small set of runtime-owned files write-protected from everyone — including the owning agent and even owner and admin — so that only the internal service that manages them can write them, while ordinary files in the same namespace stay fully editable. The agent-core conversation transcript and summary files are the canonical example — see agent-core security.

Three tokens are substituted into path patterns at evaluation time:

TokenResolves to
$selfThe evaluating agent's id
$creatorThe user who created the source
$sourceThe source slug the URI belongs to

A pattern like data://<self>/$self/** therefore gives each agent a private subtree inside the package's data namespace.

A token is substituted only when its value is a single, literal path segment. If the value is missing, contains a /, or is exactly . or .., the rule matches nothing and is skipped. This is what keeps a confining pattern confining: patterns are canonicalized after substitution, so a value carrying path structure would otherwise re-segment the rule and re-aim it — a $self row written to fence an agent into its own subtree could resolve to one naming the whole namespace, or another agent. Skipping is safe in both directions: an allow rule falls back to the source default, and a floor rule can only ever remove a capability, so a floor that stops matching grants nothing. Values that merely begin with a dot (..foo) are ordinary segments and resolve normally.

Naming the requester

A rule's users[] limits it to the caller: literal user ids, or $owner — the user who created the resource the URI addresses. The owning package tells the platform who that is (agent-core answers it for its conversations from the conversation's own record), so the path does not have to carry it. That is how a conversation is kept to its creator:

{ "pattern": "data://<self>/*/conversations/**", "permissions": { "read": false } },
{ "pattern": "data://<self>/$self/conversations/**", "permissions": { "read": true },
  "users": ["$owner"], "override": true }

users is honoured on an allow rule only — override: true with every listed capability true. An owner the platform cannot determine never matches, and on an allow rule that only withholds the grant; on a deny it would switch the deny off, so a manifest, the policy editor and the source routes all reject that shape, and the evaluator ignores such a row if one is ever stored. A floor cannot carry users.

From manifest baseline to persisted policy

At bootstrap, the loader substitutes <self> with each package's slug and aggregates every loaded package's resolved policies. When a source is first attached, the filesystem layer unions the matching baselines into the source's persisted configuration.

A baseline is keyed by a pattern, and that pattern is what scopes it. A package declaring data://<self>/** is describing its own subtree, so its default block becomes a path rule on that pattern — not the source's default. The source-level default is left to the source itself. This is what keeps N packages, each declaring a restriction for their own subtree, from compounding into a restriction on the whole source. Path rules from the baseline union in as written, and deny-wins still applies among them.

From then on the persisted source config is authoritative: owners and admins customise rules through the source configuration UI or the policy routes, and the manifest baseline is only a seed. The brain-core package ships an owner/admin skill for auditing and patching these rules conversationally — see brain-core skills.

Four enforcement layers, one evaluator

The same evaluator (evaluateUriPolicy / assertUriCapability from @neuralis/package-system) runs with identical semantics at every layer:

  1. Route — package route handlers assert the capability before dispatching to filesystem services.
  2. UI — the filesystem panel consumes a server-produced policy snapshot (tree dimming, editors); the server re-verifies on every route and never trusts the client copy.
  3. Index — the vector-index gate described above. Before the sync pipeline embeds a path it evaluates the persisted floor rows alone, read only, so a path no principal may read is never newly embedded. Because floors are role-independent, this layer needs no caller — a per-role deny belongs in the index and is filtered per caller at query time instead.
  4. Tool — the fs_* tools, the terminal working directory, and the sandboxed execute shell. The shell gate requires source-level exec for the working directory, evaluates absolute path tokens (and cwd-resolved relative writes) against the owning source's policy, denies uncovered paths, and fails closed when no policy is available. Only holders of the exec.unconfined feature bypass it — and only for commands targeting the container. The host plane has no equivalent per-role bypass: a command that resolves into a host-plane source is confined and additionally clamped by an operator-owned allowlist outside the platform's reach — the only relaxation is a mode the operator writes into that same file, never a feature. A command on a REMOTE source (a virtual desktop, or any other exec-capable connector) is gated the same way at its working directory: the source's own exec bit decides where a command may start, while what it then touches is bounded by that machine's own confinement, not by path rules. Details: agent-core security.

Filesystem connectors are deliberately not one of these layers. A connector is the raw transport for a URI scheme; policy is enforced one level above it, in the filesystem services and the routes that call them. That is why cross-package access always goes through a typed getPackageApi() adapter onto a gated service, and never onto a connector directly.

A denied capability surfaces as a structured UriPolicyDeniedError carrying the uri, the capability, and code: 'uri_policy_denied' — routes map it to HTTP 403 and tools render it as a policy-denied result without leaking internal detail.

Policy and the enabled toggle are orthogonal

URI policy answers "which URIs may this caller touch". The source enabled/disabled toggle answers "is this source live at all". A disabled source returns code: 'source_disabled' from filesystem tools regardless of what the policy would allow.

Evaluator API

ExportPurpose
evaluateUriPolicy(config, uri, ctx)Resolve effective { read, write, exec } for a URI
assertUriCapability(config, uri, capability, ctx)Throw UriPolicyDeniedError when denied
evaluateSourceLevelUriPolicy(config, ctx)Source-level resolution without path rules — "is this source visible at all"
validateManifestUriPolicies(policies, packageId)The load-time foreign-namespace rejection
resolveSelfPlaceholders(policies, slug)Substitute <self> against a package slug
pickManifestBaselinesForSource(policies, scheme)Select the baselines that apply to a data or packages source and scope each one to its own pattern, for first-attach seeding
mergeBaselines(baselines)AND-merge defaults, union paths — pure, used at source seeding. Manifest baselines contribute a neutral default (their own default is projected onto their pattern first), so the AND cannot narrow the source.
pickConnectorFloorRules(defaultPermissions)Extract the restrict-only floor rows a source connector declares on its own kind
containsPolicyFloorMarker(permissions)Whether a payload carries the floor key at all — how caller-supplied floors are rejected before any merge
validatePolicyFloorRule(rule)Validate one rule's floor extension; returns the authoring errors
assertImmutablePolicyFloorsPreserved(existing, proposed)Guard a full permissions replacement — floors must round-trip unchanged
reconcileImmutablePolicyFloors(current, trustedBaselines)Boot-time reconcile: re-derive the persisted floor set from the trusted manifests
applyTrustedSourceBaselines(input)Hold one source config to its trusted baselines — manifest rows and connector floors, merged and reconciled in one step; used at boot and before a declared source is first written, so it is never stored without its floors
manifestBaselineScheme(source)data, packages or null — the schemes that take manifest baselines
stripPolicyFloorMarkers(policies)Drop floor markers from a non-trusted package's baselines before aggregation
resolvePolicyPatternTokens(pattern, ctx)Substitute $self / $creator / $source; null when a value is unsafe, so the rule matches nothing
validatePolicyUsersRule(rule)Validate one rule's users[] — allow rules only; returns the authoring errors
registerResourceOwnerResolver(resolver) / resolveResourceOwner({ projectId, uri })The owning package declares who created the resource a URI addresses; every enforcement layer reads it to resolve $owner
normalizePolicyUri(value)Canonicalize a URI before comparison — collapse slash runs, resolve . and ..
globToRegExp(pattern)Compile a ** / * / ? pattern, anchored at both ends
UriPolicyDeniedErrorA denied capability (code: 'uri_policy_denied')
ImmutablePolicyFloorErrorA malformed floor, or an edit that would alter or remove one
CallerPolicyFloorErrorA caller-supplied payload that tried to mint a floor

The evaluation context (UriPolicyContext) carries userId, role, agentId, the optional source / creatorUserId used for token substitution, and the optional resourceOwnerUserId behind $owner — all derived from the verified session, never from caller input.

On this page