KB LabsDocs

Permissions

Last updated April 7, 2026


How plugins declare what they need, how presets compose, and what the runtime enforces.

KB Labs plugins are allow-list sandboxed. A plugin declares what it wants (filesystem paths, env vars, network hosts, platform services, shell commands, resource quotas) and the runtime refuses anything that isn't on the list. There is no deny-list: the platform separately enforces hardcoded security rules (no .env reads, no .git writes, etc.), and what the plugin gets is the intersection.

This page documents both the authoring format (what you write in your plugin source, using presets and the builder) and the runtime format (what the manifest actually ships with). They're different on purpose.

Two forms of PermissionSpec

There are two PermissionSpec types in the codebase, and they describe the same concept in different shapes:

  1. Authoring / preset form — used by combinePermissions() and all bundled presets. This is the ergonomic surface for plugin authors.
  2. Runtime form — what lives in the manifest after .build() and what the runtime actually enforces.

.build() calls toRuntimeFormat(), which converts form (1) into form (2). You don't normally have to think about it, but it matters when you read an existing manifest or wire permissions by hand.

Authoring form

Defined in platform/kb-labs-shared/packages/shared-perm-presets/src/types.ts:

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;
  };
}

fs is a single list with a mode flag. If mode === 'readWrite', every entry in allow becomes both read and write at runtime; if mode === 'read' (the default), it's read-only.

Runtime form

Defined in infra/kb-labs-plugin/packages/plugin-contracts/src/permissions.ts:

TypeScript
interface PermissionSpec {
  fs?: {
    read?: string[];
    write?: string[];
  };
  env?: { read?: string[] };
  network?: { fetch?: string[] };
  platform?: { /* see below */ };
  shell?: { allow?: string[] };
  invoke?: { allow?: string[] };
  state?: {
    namespaces?: string[];
    quotas?: {
      maxEntries?: number;
      maxSizeBytes?: number;
      operationsPerMinute?: number;
    };
  };
  quotas?: {
    timeoutMs?: number;
    memoryMb?: number;
    cpuMs?: number;
  };
}

Three things to notice:

  • fs.read and fs.write are separate lists. When the builder converts an authoring spec with mode: 'readWrite', the same globs end up in both.
  • There are two runtime-only sections: invoke (whitelist of other plugin IDs this one can call) and state (state-broker namespaces + per-namespace quotas). These don't have ergonomic builder helpers yet — you attach them by hand.
  • The platform.* subtree is richer in the runtime form — it covers all of the lifecycle surfaces (workflows, jobs, cron, environment, workspace, snapshot, execution) that aren't currently exposed via presets.

Platform services

The authoring PlatformPermissions covers the services most plugins actually need:

TypeScript
interface PlatformPermissions {
  llm?: boolean | { models?: string[] };
  vectorStore?: boolean | { collections?: string[] };
  cache?: boolean | string[];            // true = all, string[] = namespace prefixes
  storage?:
    | boolean
    | string[]
    | { read?: string[]; write?: string[] };
  database?:
    | boolean
    | {
        sql?: boolean | { tables?: string[] };
        document?: boolean | { collections?: string[] };
        kv?: boolean | { prefixes?: string[] };
        timeseries?: boolean | { metrics?: string[] };
      };
  analytics?: boolean;
  embeddings?: boolean;
  eventBus?: boolean | { publish?: string[]; subscribe?: string[] };
}

The runtime platform subtree adds more fine-grained control for lifecycle APIs — see the runtime type for workflows, jobs, cron, environment, workspace, snapshot, and execution. Each of those is either a boolean or an object with per-capability flags (e.g. workflows: { run: true, list: true, workflowIds: ['my-wf-*'] }).

The builder: combinePermissions()

Import from @kb-labs/sdk (which re-exports from @kb-labs/perm-presets):

TypeScript
import {
  combinePermissions,
  gitWorkflowPreset,
  kbPlatformPreset,
  llmAccessPreset,
} from '@kb-labs/sdk';

combinePermissions() returns a fluent PresetBuilder. Chain .with(...), .withEnv(...), etc., then call .build() to get a runtime-format spec ready to put in your manifest:

TypeScript
const pluginPermissions = combinePermissions()
  .with(gitWorkflowPreset)
  .with(kbPlatformPreset)
  .withEnv(['MY_CUSTOM_VAR'])
  .withFs({ mode: 'readWrite', allow: ['.kb/mine/**'] })
  .withNetwork({ fetch: ['api.example.com'] })
  .withPlatform({
    llm: true,
    cache: ['mine:'],
    analytics: true,
  })
  .withShell({ allow: ['git status', 'git diff'] })
  .withQuotas({ timeoutMs: 600_000, memoryMb: 512 })
  .build();

Builder methods

Every method returns the builder for chaining. All are optional.

MethodEffect
.with(preset | spec)Merge a preset or raw spec into the accumulator.
.withEnv(vars)Shortcut for .with({ env: { read: vars } }).
.withFs(fs)Add filesystem permissions (mode + allow).
.withNetwork(network)Add { fetch: [...] }.
.withPlatform(platform)Add a PlatformPermissions block.
.withStorage(storage)Shortcut for .withPlatform({ storage }).
.withDatabase(database)Shortcut for .withPlatform({ database }).
.withShell(shell)Add { allow: [...] } command whitelist.
.withQuotas(quotas)Set timeoutMs / memoryMb / cpuMs.
.build()Run toRuntimeFormat() and return the runtime-format spec.

Merge semantics

Each .with* call feeds mergeSpecs():

  • String arrays (env reads, fs allow, network fetch, shell allow, cache namespaces, storage paths) are merged as sets — duplicates are dropped, nothing is ever removed.
  • Scalars (quotas, boolean platform flags) — the later call wins.
  • fs.mode — later call wins; note that because readWrite contains read, there is effectively a one-way upgrade path. Once any merged block sets mode: 'readWrite', you won't be able to downgrade it.
  • platform.* — merged per-service. Arrays union, objects shallow-merge, booleans replace.

combinePresets()

If you just want to combine a few presets with no custom additions, use the convenience wrapper:

TypeScript
import { combinePresets, gitWorkflowPreset, npmPublishPreset } from '@kb-labs/sdk';
 
const permissions = combinePresets(gitWorkflowPreset, npmPublishPreset);

It's equivalent to combine().with(a).with(b).build().

Bundled presets

All eight presets live in platform/kb-labs-shared/packages/shared-perm-presets/src/presets/. They're exported from @kb-labs/sdk with a Preset suffix.

minimalPreset

Baseline for plugins that need nothing but a Node.js environment.

TypeScript
env: { read: ['NODE_ENV', 'PATH', 'LANG', 'LC_ALL', 'TZ'] }

gitWorkflowPreset

For plugins that shell out to git or use simple-git. Grants HOME (for ~/.gitconfig), SSH sockets (for key auth), and read-write access to .git directories and gitignore files.

TypeScript
env: {
  read: [
    'HOME', 'USER', 'PATH', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR',
    'GIT_*', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'NODE_ENV',
  ],
}
fs: {
  mode: 'readWrite',
  allow: ['**/.git/**', '**/.gitignore', '**/.gitattributes'],
}

npmPublishPreset

For plugins that publish packages. Grants npm token env vars, package-manifest files, and network access to the npm registries.

TypeScript
env: {
  read: [
    'HOME', 'USER', 'PATH', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ',
    'NPM_TOKEN', 'NPM_AUTH_TOKEN', 'NODE_AUTH_TOKEN', 'npm_*',
    'NODE_ENV', 'NODE_OPTIONS',
  ],
}
fs: {
  mode: 'readWrite',
  allow: [
    '**/package.json', '**/package-lock.json', '**/pnpm-lock.yaml',
    '**/.npmrc', '**/.npmignore',
  ],
}
network: { fetch: ['registry.npmjs.org', 'npm.pkg.github.com'] }

kbPlatformPreset

For first-party KB Labs plugins. Grants KB_* env vars and read-write on the .kb/** directory.

TypeScript
env: {
  read: ['HOME', 'USER', 'PATH', 'TMPDIR', 'NODE_ENV', 'KB_*'],
}
fs: {
  mode: 'readWrite',
  allow: ['.kb/**'],
}

llmAccessPreset

For plugins that call external LLM APIs directly (as opposed to going through the platform's useLLM() hook, which is usually what you want).

TypeScript
env: {
  read: [
    'OPENAI_API_KEY', 'OPENAI_ORG_ID', 'OPENAI_BASE_URL',
    'ANTHROPIC_API_KEY', 'AZURE_OPENAI_*', 'LLM_*',
  ],
}
network: {
  fetch: ['api.openai.com', 'api.anthropic.com', '*.openai.azure.com'],
}

Prefer useLLM() (via .withPlatform({ llm: true })) over llmAccessPreset. The hook routes through the platform's LLM adapter, gets analytics and quota tracking for free, and doesn't bake API keys into your plugin's permission surface.

vectorStorePreset

For plugins that talk to vector databases (Qdrant, Pinecone, Weaviate) directly.

TypeScript
env: {
  read: [
    'QDRANT_URL', 'QDRANT_API_KEY', 'QDRANT_*',
    'PINECONE_API_KEY', 'PINECONE_ENVIRONMENT', 'PINECONE_*',
    'WEAVIATE_URL', 'WEAVIATE_API_KEY', 'WEAVIATE_*',
    'VECTOR_STORE_*', 'EMBEDDING_*',
  ],
}
network: {
  fetch: [
    'localhost', '127.0.0.1',
    '*.qdrant.io', '*.pinecone.io', '*.weaviate.io',
  ],
}

ciEnvironmentPreset

For plugins that behave differently under CI — typically they want to read CI-detection vars and tokens.

TypeScript
env: {
  read: [
    'CI', 'CI_*', 'CONTINUOUS_INTEGRATION',
    'GITHUB_TOKEN', 'GITHUB_*', 'GH_TOKEN',
    'GITLAB_*', 'CI_JOB_TOKEN',
    'JENKINS_*', 'BUILD_*',
    'BRANCH_NAME', 'TAG_NAME', 'COMMIT_SHA',
  ],
}

fullEnvPreset

fullEnvPreset grants access to every environment variable via the wildcard ['*']. It bypasses env filtering entirely. Use it only for plugins you fully trust and control — never for code loaded from the marketplace.

TypeScript
env: { read: ['*'] }

Conversion: authoring → runtime

toRuntimeFormat() is the single transform between the two shapes. It does exactly one thing that isn't a simple copy:

TypeScript
// Authoring
fs: {
  mode: 'readWrite',
  allow: ['.kb/mine/**', '**/package.json'],
}
 
// Runtime (after .build())
fs: {
  read:  ['.kb/mine/**', '**/package.json'],
  write: ['.kb/mine/**', '**/package.json'],
}

When mode !== 'readWrite', only fs.read is populated. Everything else (env, network, shell, platform, quotas) passes through unchanged. If your spec has no allow entries, fs is stripped entirely rather than left as an empty object.

Secure defaults

Even if you set permissions: undefined, the runtime applies DEFAULT_PERMISSIONS from plugin-contracts/src/permissions.ts:

TypeScript
{
  fs: {
    read: ['.'],   // cwd only
    write: [],     // only the plugin's outdir (added by runtime)
  },
  network: { fetch: [] },       // no network
  env: { read: [] },             // only NODE_ENV, CI, DEBUG (always allowed)
  platform: {
    llm: false, vectorStore: false, cache: false, storage: false,
    analytics: false, embeddings: false, events: false,
    workflows: false, jobs: false, cron: false,
    environment: false, workspace: false, snapshot: false, execution: false,
  },
  shell: { allow: [] },          // shell disabled
  invoke: { allow: [] },          // no cross-plugin calls
  state: { namespaces: [] },      // no state broker
}

The short version: a plugin without declared permissions can read files under cwd, write only to its own output directory, and do nothing else.

Where permissions apply

The manifest has a plugin-level permissions field and every handler declaration (cli.commands[], rest.routes[], ws.channels[], workflows.handlers[], webhooks.handlers[], jobs.handlers[], cron.schedules[]) has its own optional permissions field.

At runtime, getHandlerPermissions(manifest, host, id) (manifest.ts) resolves the effective set with a shallow merge: handler-level fields override plugin-level fields where both are present, but nothing is deeply merged. If your handler declares a different fs, it replaces the plugin fs entirely — it does not union.

Handler-level permissions are a replacement, not an addition. If you need the plugin-wide allow-lists plus a few extras for one command, rebuild the full spec for that command rather than trying to "extend" the default.

Example: a realistic plugin

From plugins/kb-labs-commit-plugin/packages/commit-cli/src/manifest.ts:

TypeScript
import {
  combinePermissions,
  gitWorkflowPreset,
  kbPlatformPreset,
} from '@kb-labs/sdk';
import { COMMIT_ENV_VARS, COMMIT_CACHE_PREFIX } from '@kb-labs/commit-contracts';
 
const pluginPermissions = combinePermissions()
  .with(gitWorkflowPreset)
  .with(kbPlatformPreset)
  .withEnv([...COMMIT_ENV_VARS])
  .withFs({
    mode: 'readWrite',
    allow: ['.kb/commit/**'],
  })
  .withPlatform({
    llm: true,                      // routes through useLLM()
    cache: [COMMIT_CACHE_PREFIX],   // 'commit:' namespace only
    analytics: true,
  })
  .withQuotas({
    timeoutMs: 600_000,  // 10 min for LLM-heavy flows
    memoryMb: 512,
  })
  .build();
 
export const manifest = {
  schema: 'kb.plugin/3',
  id: '@kb-labs/commit',
  version: '0.1.0',
  // ...
  permissions: pluginPermissions,
  // ...
};

The result after .build():

  • fs.read / fs.write.git, gitignore, gitattributes, .kb/**, .kb/commit/** (all merged from git-workflow + kb-platform + the custom add).
  • env.read — the union of git-workflow's system + GIT_* + SSH_* list, kb-platform's KB_*, and whatever COMMIT_ENV_VARS exports.
  • platformllm: true, cache: ['commit:'], analytics: true. No storage, no vector store, no database, nothing else.
  • quotas — 10-minute timeout, 512 MB memory limit.

No shell access, no extra network (the LLM call is proxied through the platform adapter), no cross-plugin invocations. That's the shape of a well-behaved first-party plugin.

Rules of thumb

  • Start with presets. If a stock preset covers what you need (minimal, kbPlatform, gitWorkflow, ...), prefer it — it's documented, reviewed, and won't drift.
  • Prefer platform hooks over raw API access. useLLM() / useCache() / useStorage() give you quota tracking, observability, and adapter swapping for free. Raw llmAccessPreset + fetch to api.openai.com bypasses all of that.
  • Be tight with fs. .kb/<plugin-name>/** is almost always enough. Avoid ** or ./** unless you really need to walk the entire project.
  • Set realistic quotas. Default timeouts are low; LLM-heavy commands need timeoutMs in the hundreds of thousands. Memory limits are a safety net — pick something sane (256–512 MB for most plugins).
  • Never use fullEnvPreset in a published plugin. Ever.
Permissions — KB Labs Docs