Skip to content

Capability Model

The capability model defines how xript enforces its default-deny security posture. Every sensitive operation is gated behind a named capability that scripts must be explicitly granted before use. This document specifies how capabilities are declared, requested, granted, and enforced.

  1. Default-deny. Scripts start with zero capabilities. They can only call ungated bindings until capabilities are explicitly granted.
  2. Explicit grants. The host application decides which capabilities a script receives. Scripts cannot grant themselves capabilities.
  3. No ambient authority. Having one capability does not imply access to another. Each capability is independent.
  4. Transparent to the extender. Extenders should always know which capabilities their script needs and what each one grants.

Capabilities are declared in the manifest’s capabilities section:

{
"capabilities": {
"modify-player": {
"description": "Modify the player's stats, inventory, and equipment.",
"risk": "medium"
},
"network": {
"description": "Make HTTP requests to allowed domains.",
"risk": "high"
},
"storage": {
"description": "Read and write persistent data for this mod.",
"risk": "low"
}
}
}

Each capability has:

  • description (required): A human-readable explanation of what the capability grants, shown to users when a script requests it.
  • risk (low, medium, high): An advisory level that helps users make informed decisions. Does not affect runtime behavior.

Capability names should be:

  • Lowercase with hyphens (modify-player, not modifyPlayer or MODIFY_PLAYER)
  • Action-oriented when possible (read-files, modify-world, send-messages)
  • Scoped by domain when the API is large (player-read, player-write, world-read, world-write)

Individual functions are gated by referencing a capability name in their capability field:

{
"bindings": {
"player": {
"description": "Player functions.",
"members": {
"getHealth": {
"description": "Returns the player's health.",
"returns": "number"
},
"setHealth": {
"description": "Sets the player's health.",
"params": [{ "name": "value", "type": "number" }],
"capability": "modify-player"
}
}
}
}
}

In this example, player.getHealth() is always available (no capability field), but player.setHealth() requires the modify-player capability.

Functions without a capability field are accessible to all scripts unconditionally. This is the common case for read-only operations that pose no risk. The manifest author decides what is gated; the default is “available.”

A function can reference at most one capability. If a function logically requires multiple permissions, either:

  • Create a composite capability (e.g., admin that implies broad access)
  • Split the function into smaller, individually-gated functions

The manifest does not support listing multiple capabilities per function. This is intentional: the simplicity of “one function, one gate” makes the model easy to reason about for both authors and extenders.

  1. Declaration: The manifest declares which capabilities exist and which bindings they gate.
  2. Request: When a script is loaded, the runtime inspects which capabilities it needs (determined by which gated functions it calls, or declared in a script manifest).
  3. Grant decision: The host application receives the capability request and decides whether to grant each one. This decision is application-specific: it might be automatic, user-prompted, or policy-driven.
  4. Enforcement: The runtime makes only granted capabilities available. Calls to gated functions without the required capability fail with a CapabilityDeniedError.

Calling a Gated Function Without the Capability

Section titled “Calling a Gated Function Without the Capability”

When a script calls a function it doesn’t have the capability for, the runtime must:

  1. Throw a CapabilityDeniedError with a clear message including the capability name
  2. Not execute the function (no partial execution, no side effects)
  3. Allow the script to catch the error and continue running

Example error message: CapabilityDeniedError: calling "player.setHealth" requires the "modify-player" capability, which has not been granted to this script.

Once a script’s capabilities are set at load time, they cannot change during execution. A script cannot request additional capabilities mid-run. This prevents escalation attacks and simplifies the security model.

If a host application wants to support dynamic permission prompting, it should do so during the grant phase (before execution begins), not during script execution.

Extenders need to know which capabilities exist and what they grant. The manifest provides this information:

  • Tooling: xript-validate can list all capabilities and which functions they gate
  • Typegen: Generated TypeScript types include JSDoc annotations indicating which functions require capabilities
  • Docgen: Generated documentation groups functions by capability requirement

An extender writing a script should be able to look at the generated types or docs and immediately see: “I need modify-player to use player.setHealth() and player.addItem().”

RBAC introduces complexity: roles, role hierarchies, role assignments. xript’s capability model is deliberately simpler. Capabilities are flat (no hierarchy), binary (granted or not), and bound directly to functions. This maps cleanly to the manifest structure and is easy to validate statically.

Checking capabilities on every function call would add runtime overhead and require the host to maintain per-call permission state. Per-script grants are simpler, faster, and match the mental model: “this script is allowed to do these things.”

Why Advisory Risk Levels Instead of Enforced Tiers?

Section titled “Why Advisory Risk Levels Instead of Enforced Tiers?”

The risk field is advisory because risk is context-dependent. Writing to storage might be low risk for a game mod but high risk for a financial tool. The manifest author sets the risk level based on their application’s context; the runtime shows it to users but doesn’t enforce behavior differences.