KB LabsDocs

Permissions Model

Last updated April 7, 2026


How the platform sandboxes what plugins can access — allow-list, runtime shims, and defense-in-depth.

KB Labs is designed to run untrusted code. Every plugin declares up front what it needs — which files it can read, which env vars it can see, which network hosts it can reach, which platform services it can touch — and the runtime enforces those declarations at every syscall. A plugin that tries to do something it didn't declare gets a permission error before the operation happens.

This page is the conceptual picture. For the full schema and the builder API see Plugins → Permissions. For how the sandbox interacts with execution modes see Concepts → Execution Model.

Allow-list, not deny-list

The permission model is allow-list only. Plugins declare what they want; anything undeclared is forbidden. There's no "block these files" or "deny these hosts" concept at the plugin level.

Why allow-list?

  • Secure by default. A plugin with no declared permissions can do almost nothing. You opt in to capabilities explicitly.
  • Auditable. The list of things a plugin can do is a single data structure in the manifest. Review it before install and you know the blast radius.
  • Composable. Preset-based composition makes "I want git access and kb-platform access and an LLM" a three-line declaration. Deny-list models don't compose cleanly.
  • Aligned with execution modes. Container execution uses OS-level allow-lists for network and filesystem. A deny-list model wouldn't map onto those primitives.

The platform itself enforces additional hard rules independent of plugin declarations — even if a plugin declares fs.allow: ['**'], the platform refuses reads of .env, .git/config, and other sensitive files. Those rules are not configurable by plugins.

What you can declare

TypeScript
interface PermissionSpec {
  fs?: {
    mode?: 'read' | 'readWrite';
    allow?: string[];
  };
  env?: {
    read?: string[];
  };
  network?: {
    fetch?: string[];
  };
  platform?: PlatformPermissions;
  shell?: {
    allow?: string[];
  };
  quotas?: {
    timeoutMs?: number;
    memoryMb?: number;
    cpuMs?: number;
  };
}

Five categories plus resource quotas:

  • fs — filesystem access. Glob patterns relative to cwd or absolute. mode: 'read' vs mode: 'readWrite'.
  • env — environment variables. Exact names or prefix patterns (KB_*, GIT_*).
  • network — HTTP fetch. Host patterns (api.openai.com, *.qdrant.io).
  • platform — platform services. Booleans or scoped access (cache: ['commit:'], llm: { models: ['gpt-4o-mini'] }).
  • shell — shell commands. Whitelist of specific commands.
  • quotas — timeouts, memory, CPU limits.

Every unset field is forbidden by default. Declaring nothing means: read from cwd, no env vars, no network, no platform services, no shell, no quotas. The plugin can load and run its own code but can't do anything meaningful.

Declaration layer: the manifest

Plugins declare permissions in the manifest. The typical pattern uses combinePermissions() with preset composition:

TypeScript
import {
  combinePermissions,
  gitWorkflowPreset,
  kbPlatformPreset,
} from '@kb-labs/sdk';
 
const permissions = combinePermissions()
  .with(gitWorkflowPreset)               // git operations + SSH keys + HOME
  .with(kbPlatformPreset)                // KB_* env vars + .kb/ directory
  .withFs({ mode: 'readWrite', allow: ['.kb/commit/**'] })
  .withPlatform({ llm: true, cache: ['commit:'] })
  .withQuotas({ timeoutMs: 600_000, memoryMb: 512 })
  .build();
 
export const manifest = {
  schema: 'kb.plugin/3',
  // ...
  permissions,                           // plugin-wide defaults
  cli: {
    commands: [
      {
        id: 'commit:push',
        handler: './cli/commands/push.js#default',
        permissions: pushSpecificPermissions, // per-handler override
      },
    ],
  },
};

Permissions exist at two scopes:

  • Plugin-widemanifest.permissions. Default for all handlers that don't override.
  • Per-handlermanifest.cli.commands[i].permissions, manifest.rest.routes[i].permissions, etc. Replaces the plugin-wide defaults for that specific handler (not merged).

Enforcement layer: runtime shims

Declarations in the manifest are worthless without enforcement. The enforcement layer is a set of runtime shims that intercept every filesystem, network, env, and shell call the plugin makes.

Plugin code doesn't call fs.readFile from Node. It calls ctx.runtime.fs.readFile — which is a shim that:

  1. Checks the requested path against permissions.fs.read / permissions.fs.write.
  2. If allowed, performs the underlying fs.readFile call.
  3. If denied, throws a PermissionError before any IO happens.

Same story for ctx.runtime.fetch (checks permissions.network.fetch), ctx.runtime.env (checks permissions.env.read), and ctx.runtime.shell (checks permissions.shell.allow).

Platform services (useLLM(), useCache(), useStorage()) use a separate enforcement path — the platform wrapper layer checks permissions.platform.* before forwarding the call to the underlying adapter.

This is enforced identically in every execution mode. In-process, worker-pool, and container all use the same shim layer. Container mode adds a second OS-level enforcement layer on top (cgroups, network namespaces) as defense in depth.

Composition

Presets are building blocks. The SDK ships eight:

  • minimalPreset — baseline (NODE_ENV, PATH, LANG, LC_ALL, TZ).
  • gitWorkflowPreset — HOME, USER, GIT_*, SSH_*, read-write **/.git/** and gitignore/gitattributes files.
  • kbPlatformPresetKB_* env vars and read-write .kb/**.
  • npmPublishPreset — npm tokens, package.json and lockfile access, network to registries.
  • llmAccessPreset — OpenAI/Anthropic env vars and network (use useLLM() instead when possible).
  • vectorStorePreset — Qdrant/Pinecone/Weaviate env vars and network.
  • ciEnvironmentPreset — CI/CD env vars.
  • fullEnvPreset — wildcard env access. Trusted plugins only.

Each preset is a PermissionSpec that you merge with combinePermissions(). The builder handles deep merging — string arrays union, booleans combine, quotas take the stricter value.

See Plugins → Permissions → Bundled presets for the exact specs.

Resource quotas

Beyond access control, the permission spec includes resource quotas:

TypeScript
quotas: {
  timeoutMs: 600_000,   // 10 minutes wall-clock
  memoryMb: 512,         // 512 MB RSS
  cpuMs: 120_000,        // 2 minutes of CPU
}
  • timeoutMs — enforced in every execution mode. Plugins exceeding it are killed.
  • memoryMb — strictly enforced in worker-pool and container modes via cgroups. Advisory in-process.
  • cpuMs — enforced in container mode via cgroups. Not enforced elsewhere.

The stricter mode you pick, the more quotas are hard limits. In-process mode respects timeouts but not memory or CPU; container mode enforces all three.

Runtime behavior: what happens on denial

When a shim detects a permission violation, it throws a specific error:

TypeScript
class PermissionError extends Error {
  code: 'EACCES' | 'ENOTPERMITTED';
  permission: 'fs' | 'env' | 'network' | 'shell' | 'platform';
  resource: string;     // path, URL, env name, command, service
  pluginId: string;
}

The error propagates up to the handler. If the handler catches it and continues, the caller sees whatever the handler decided to do with the error. If the handler doesn't catch it, it bubbles up to the execute wrapper, which maps it to a CommandResult with exitCode: 1 and a PERMISSION_DENIED error code.

Either way, the original operation — the actual file read, network fetch, whatever — never happened. The shim caught it before touching the underlying resource.

The host and declared permissions

Every plugin handler runs under a specific host (cli, rest, workflow, webhook, ws). The define* helpers each enforce the correct host via a guard inside the wrapped handler. Permissions are independent of host — a plugin that declares fs.allow: ['.kb/**'] can read .kb/** from any host it provides handlers for.

This means you don't need to duplicate permissions per host. Declare them once at the plugin level or once per handler; both CLI commands and REST routes in the same plugin can share the same permission set.

The effective permission set

When the runtime invokes a handler, it computes the effective permissions for that specific invocation:

  1. Start with DEFAULT_PERMISSIONS (secure baseline).
  2. Merge in the plugin-wide manifest.permissions.
  3. If the handler has its own permissions field, replace (not merge) the relevant sections.
  4. Apply the platform-wide hardcoded security rules (never readable files, never-writable directories).

Result: a concrete PermissionSpec that the shim layer uses for every operation in this invocation.

getHandlerPermissions(manifest, host, handlerId) in plugin-contracts is the function that computes this — use it in tests or introspection tools if you want to see what a specific handler actually has access to.

Defense in depth

The full enforcement stack, from innermost to outermost:

  1. Shim layer (runtime) — the SDK shims intercept every call from plugin code and check against declared permissions. Same in every execution mode.
  2. Wrapper layer (platform)useLLM(), useCache(), useStorage(), etc. enforce platform.* permissions and add analytics/rate-limiting.
  3. Worker boundary (worker-pool mode) — plugin code runs in a V8 isolate. A crash doesn't affect the service.
  4. OS namespace (container mode) — Docker cgroups and namespaces enforce resource limits and network/filesystem isolation at the kernel level.
  5. Platform hardcoded rules — files and env vars that no plugin can access regardless of declared permissions (e.g. .env).

An attempt to read /etc/passwd from a plugin with fs.allow: ['**'] is blocked by the shim (layer 1). If layer 1 were bypassed, container mode would block it at the OS level (layer 4). And even with both bypassed, .env is on the hardcoded blocklist (layer 5).

Each layer is independent — a bug in one doesn't cascade into the others.

Validation at load time

Before a plugin is loaded, the marketplace and discovery layer validate its manifest against ManifestV3Schema. Permission fields are schema-validated — malformed permissions (wrong types, unknown fields) are caught here, not at runtime.

Runtime validation is stricter: even if a permission spec passes schema validation, the actual fs/network/env paths are checked against hardcoded platform rules at invocation time.

This means there are two failure modes for permissions:

  • Load-time: manifest schema violation. The plugin doesn't load at all.
  • Runtime: the plugin loaded fine, but a specific invocation tried to do something outside its declared scope (or outside the platform's hardcoded allow-list).

The former is the author's fault; the latter is the runtime enforcing the contract.

When plugin permissions are wrong

You'll see two main failure shapes:

Plugin fails at install: schema error

Invalid manifest: permissions.fs.allow must be an array of strings

Fix the manifest. Run pnpm kb plugin:validate before publishing to catch these locally.

Plugin fails at runtime: permission denied

PermissionError: fs.read not allowed: '/var/log/some-file'
  at runtimeFs.readFile (...)
  at handler.execute (...)

Two options:

  1. Update the permission declaration if the access is legitimate. Add /var/log/** to fs.allow (or better, a narrower glob).
  2. Fix the handler if the access is a bug. Plugin code shouldn't be reaching into /var/log.

Don't blindly widen permissions to make errors go away. Every added path or host is an expansion of attack surface.

The golden rule for plugin authors

Declare the minimum that works. Start with the narrowest set of permissions that makes your handlers functional, and expand only when a specific feature requires it. If you're not sure what you need, start with minimalPreset and see what breaks.

For platform services, prefer hooks (useLLM(), useCache()) over raw API access — the hooks route through the platform wrapper layer, which adds analytics, rate limiting, and proper attribution. Raw API access bypasses those layers.

For filesystem access, scope your globs to the plugin's own workspace (.kb/<your-plugin>/**). Don't request access to the entire repo unless you're an analysis plugin that needs it.

For network access, name specific hosts. Don't use wildcards except for CDN-style patterns (*.openai.com).

Permissions Model — KB Labs Docs