Your agent decides what to call. Berth decides what those calls can touch, and the Linux kernel enforces it.
Agents act through tools: a filesystem, a shell, a browser, a code interpreter. Berth runs those tools in a sandbox and gives each one a manifest listing exactly what it may touch. Before the tool's first line of code runs, the manifest is compiled into kernel rules. So when a prompt-injected model tries to write outside its folder, it isn't talked out of it by a system prompt. The write fails with EACCES.
Keep your agent and keep your framework. Berth sits underneath the tools.
This README is laid out along the C4 model: the system in its context, the containers it runs as, the components inside a sandbox, and the code you write against it. If you only want to see it work, start with the demo.
No API key and no LLM needed: this demo calls the tool directly, the way an agent would.
git clone https://github.com/Ash20pk/BerthOS && cd BerthOS
corepack enable && pnpm install && pnpm build
node packages/cli/bin/berth.js doctor --fix # can this machine's kernel enforce anything?
cd examples/kernel-says-no && pnpm start--- inside the declared scope ---
write /workspace/hello.txt -> ok, read back: "hello from a sandbox"
--- outside the declared scope ---
write /etc/berth-should-not-exist.txt -> EACCES: permission denied, open '/etc/berth-should-not-exist.txt'
PASS — the capability line in berth.yml is the boundary, and the kernel is the one holding it.
Nothing in that script, or in the app's own code, checks the second path. The kernel refused it.
On a Mac, run
doctorfirst. Docker Desktop's VM has no Landlock, so nothing would be enforced, and the demo says so and exits non-zero rather than faking a pass.berth doctor --fixsets up a Colima VM whose kernel can enforce, or use the microVM runtime, which boots Berth's own kernel. Linux 6.7+ works out of the box.
More demos, each proving one boundary: a fully compromised model told to backdoor /etc, attacker-chosen code with no network, and a tamper-evident audit trail catching an edited record. See the catalog.
Who uses Berth, and what it touches.
Your agent is outside the system boundary on purpose: Berth doesn't run it, prompt it or wrap it. It sees ordinary tools.
Any MCP client, no code. berth mcp is an MCP server. It boots the sandbox itself, exposes the app's exports as tools, and shuts the sandbox down when your client disconnects.
node packages/cli/bin/berth.js mcp --app filesystem --app-dir apps/filesystem --warm # build the image once
claude mcp add berth-filesystem -- node /abs/path/BerthOS/packages/cli/bin/berth.js \
mcp --app filesystem --app-dir /abs/path/BerthOS/apps/filesystemAsk your agent to write to /etc and it gets back a denial that tells it why, not just an error code:
BERTH CAPABILITY DENIAL
denied: open(2) on /etc/berth-should-not-exist.txt (EACCES: permission denied)
denied-by: the kernel — a Landlock ruleset compiled from "filesystem"'s berth.yml, applied before the app's first line ran
fix: none available — a berth.yml filesystem scope may only name /workspace, /context, /tmp, /app
Setup for Claude Desktop, Cursor and Colima: MCP quickstart.
Your own tool-calling loop. Boot a sandbox, hand its tools to the loop you already run. This path works from a clone for now: Computer and the adapters live in the experimental agent framework, which isn't published. Moving them into their own package is on the roadmap.
import { openai } from "@ai-sdk/openai";
import { generateText, stepCountIs } from "ai";
import { Computer, toAiSdkTools } from "@berthos/agents";
const computer = await Computer.boot({ apps: ["apps/filesystem"] });
const tools = await toAiSdkTools(computer.tools);
await generateText({
model: openai("gpt-4o"),
tools,
stopWhen: stepCountIs(5),
prompt: "Write a summary to /workspace/notes.md",
});
await computer.stop();| Your stack | Call |
|---|---|
| Vercel AI SDK | toAiSdkTools(computer.tools) |
| LangChain / LangGraph | toLangChainTools(computer.tools) |
| Anything else | toToolSpecs(computer.tools): name, description, JSON Schema, and a call function |
Both adapters are optional peer dependencies. Full example: examples/agents/with-vercel-ai-sdk.
Zooming into Berth: the separately running pieces, and how they talk. "Container" here is C4's word for a runnable unit, not only a Docker container.
| Container | Code | What it does |
|---|---|---|
| berth CLI | packages/cli |
Every command; bundles apps, boots and stops sandboxes, speaks MCP, writes the audit trail, runs attest |
| Container sandbox | packages/docker-orchestrator |
Alpine base image, container lifecycle, hot reload, snapshots. The default runtime |
| microVM sandbox | packages/vmm |
berth-vmm boots a libkrun VM (HVF on macOS) from a pinned kernel and rootfs, with no network device; egress only through a dialer on the host. See the microVM runtime |
| Audit trail | packages/audit |
Structured records with a hash chain and payload redaction; reference |
| Registry server | packages/registry-server |
Local app registry; reference |
| Mesh coordinator | packages/mesh-coordinator |
Stable mesh IPs and mutual peer matching; reference |
| Deploy adapters | packages/adapters |
One DeployAdapter interface, implemented for E2B, Daytona and Kubernetes |
Both sandboxes run the same apps under the same policy. The microVM adds a second wall, a hypervisor boundary around the whole sandbox, and brings its own kernel, so enforcement doesn't depend on the host's.
Zooming into one sandbox. Every component runs as its own uid under its own Landlock and seccomp policy; only the init process runs as root, and only until the others are started.
The daemons start only when an app declares the capability that needs them: no /context, no Semantic FS; no browser:*, no display stack.
| Component | Code | Reference |
|---|---|---|
| Init | entrypoint.sh, packages/vmm/init (berth-init) |
Kernel enforcement, resource limits, secrets |
| agent-init | packages/agent-init |
Kernel enforcement |
| Context bus | packages/context-bus-daemon |
Context bus |
| Semantic FS | packages/semantic-fs-daemon |
Semantic FS |
| Egress broker | egress-broker.cjs |
Egress broker |
| GitHub API broker | github-api-broker.cjs |
GitHub API scoping |
| Mesh daemon | packages/mesh-daemon |
Mesh |
| App | What it gives an agent | Declares |
|---|---|---|
filesystem |
Read and write files | filesystem:read/write:/workspace |
code-interpreter |
Run Python, JavaScript or shell | filesystem:write:/workspace, no network |
terminal |
A real shell you can watch live in the browser | filesystem:write:/workspace |
browser-native |
Headless Chromium you can watch over VNC | browser:navigate:* |
web-fetch |
Call APIs and read web pages, on the hosts you list | network:host:<host>, one per host |
git |
Clone, branch, commit, diff, push and pull in the workspace, over HTTPS to the hosts you list | filesystem:write:/workspace, network:host:github.com |
postgres |
Query your PostgreSQL database; read-only by default | network:host:<db>:5432, a DATABASE_URL secret |
mysql |
Query your MySQL or MariaDB database; read-only by default | network:host:<db>:3306, a DATABASE_URL secret |
github-assistant |
Read repos, open issues | github:read:repos, github:write:issues |
notes |
Stateful notes, persisted to disk | filesystem:write:/workspace |
Several apps can share one sandbox, and each keeps its own policy and its own uid.
The code you write is a resident app: a manifest plus a handler. This is the whole of one.
# berth.yml
name: hello-world
version: 0.1.0
capabilities: [] # touches nothing, so it can reach nothing
exports:
- name: ping
output: { message: string }// src/index.ts
import { defineApp } from "@berthos/sdk";
import { z } from "zod";
export default defineApp((app) => {
app.export({
name: "ping",
output: z.object({ message: z.string() }),
handler: () => ({ message: "pong" }),
});
});berth init my-app && cd my-app
berth dev # boots it in the sandbox, reloads on save
berth test # checks the exports match the manifest and calls each oneHow a capability line becomes a kernel rule, step by step:
- Declare.
berth.ymlnames the capabilities the app needs (filesystem:write:/workspace,network:connect:443) and the functions it exports.packages/manifest-schemaparses and validates it. - Compile. At boot, the capabilities become a policy file: write and read paths, ports, cgroup limits. Anything undeclared is denied by default: files, outbound connections, raw sockets.
- Enforce.
agent-initapplies the policy as a Landlock ruleset and seccomp filters, drops to the app's uid, and only then execs the app. - Connect. The app's exports are served over RPC by the SDK runtime (Python), and show up in your agent as ordinary tools.
The rest is in Resident apps, the SDK reference and the manifest reference. The manifest format and the attestation record are also standalone, versioned specs: capability manifest, attestation record.
- Kernel-enforced: filesystem read and write scopes, outbound TCP, UDP and raw sockets, namespace creation, and isolation between apps. This is real on any Linux 6.7+ kernel, and in the microVM, which boots its own kernel.
- Broker-enforced: browser hostnames and GitHub API verbs go through a proxy that checks them, because the kernel sees ports, not hostnames. Which capability is enforced at which level: enforcement.
- It won't pretend. On a kernel that can't enforce,
berth doctorsays so and the demos fail. Everyberth mcpsession writes its tool calls to a hash-chained audit trail, andberth attest <runId>turns a session into a record of what ran and the enforcement measured for its boot. Anyone can check the record with a standalone script, and it saysNOT_ENFORCEDwhen nothing was enforced. - Not a defence against root on the host. Anyone who can
docker execinto the container bypasses all of it. What's in scope and what isn't: threat model.
| MCP quickstart | Berth in Claude Code, Claude Desktop or Cursor in five minutes |
| Quickstart | Install, run, scaffold, the CLI reference, releasing |
| Resident apps | Building your own tools |
| microVM runtime | Running sandboxes in a microVM instead of Docker |
| Enforcement | Every capability and what enforces it, per platform |
| Threat model | What holds, against whom, and what's out of scope |
| Roadmap | What works today, what's next, and where to help |
Every subsystem has a reference page in docs/, and design write-ups live in docs/design/. The diagrams above are generated by scripts/c4-diagrams.mjs; edit it and run it to change them.
Early, and built by one maintainer, so expect APIs to move before 1.0. The sandbox publishes to npm as @berthos/* (npm install -g @berthos/cli) and to PyPI as berthos-sdk; the first-party apps, the demos and the experimental agent framework live in this repo. The unrelated @berth/* packages on npm belong to a different project.
Something not working? Run berth doctor first; it answers most "it built but nothing was enforced" reports in one line. Then file a bug, send workflow feedback, or pitch a resident app. CONTRIBUTING.md has the wishlist.
Apache-2.0. See LICENSE.