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:
- Authoring / preset form — used by
combinePermissions()and all bundled presets. This is the ergonomic surface for plugin authors. - 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:
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:
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.readandfs.writeare separate lists. When the builder converts an authoring spec withmode: 'readWrite', the same globs end up in both.- There are two runtime-only sections:
invoke(whitelist of other plugin IDs this one can call) andstate(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:
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):
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:
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.
| Method | Effect |
|---|---|
.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 becausereadWritecontainsread, there is effectively a one-way upgrade path. Once any merged block setsmode: '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:
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.
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.
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.
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.
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).
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.
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.
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.
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:
// 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:
{
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:
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'sKB_*, and whateverCOMMIT_ENV_VARSexports. - platform —
llm: 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. RawllmAccessPreset+fetchtoapi.openai.combypasses 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
timeoutMsin the hundreds of thousands. Memory limits are a safety net — pick something sane (256–512 MB for most plugins). - Never use
fullEnvPresetin a published plugin. Ever.