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: you call the tool directly, the way an agent would. On an Apple silicon Mac or Linux with KVM, there's nothing else to install: each sandbox is a small VM that boots Berth's own kernel.
npm install -g @berthos/cli
git clone https://github.com/Ash20pk/BerthOS && cd BerthOS
corepack enable && pnpm install && pnpm build
cd apps/filesystem && berth dev # first run downloads the VM (about 140 MB, each file checked against its sha256)apps/filesystem declares filesystem:write:/workspace and nothing else. In a second terminal, from the same folder:
berth rpc filesystem --export write_file --input '{"path":"hello.txt","content":"hello from a sandbox"}'
berth rpc filesystem --export read_file --input '{"path":"hello.txt"}'
# { "content": "hello from a sandbox" }
berth rpc filesystem --export write_file --input '{"path":"/tmp/escape.txt","content":"x"}'
# Error: "/tmp/escape.txt" ... is outside /workspace. (EACCES: permission denied, open '/tmp/escape.txt')Nothing in the app's code checks the second path. The capability line in berth.yml was compiled into a Landlock ruleset before the app started, and the kernel refused the write. Try /etc and it's refused even earlier: the VM's root filesystem is read-only.
The VM is up in about half a second. On an Apple M4, the first tool call over MCP takes 0.63 to 0.85 s, and there is no image to build first. berth doctor checks that this machine can run it. Details: the microVM runtime, and what it doesn't run yet: limits.
Intel Mac, Windows, or Linux without KVM? Berth runs its sandboxes in Docker there, on a kernel with Landlock: Linux 6.7+ works out of the box, and on an Intel Mac
berth doctor --fixsets up Colima.berth testandberth deployalso build a container image, so they need Docker on any machine.
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.
berth mcp --app filesystem --app-dir apps/filesystem --warm # first run: install the VM and boot once
claude mcp add berth-filesystem -- berth 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 |
| microVM sandbox | packages/vmm |
berth-vmm boots a libkrun VM (HVF on macOS, KVM on Linux) from a pinned kernel and rootfs, with no network device; egress only through a dialer on the host. The default wherever it can run. See the microVM runtime |
| Container sandbox | packages/docker-orchestrator |
Alpine base image, container lifecycle, hot reload, snapshots. Used where the VM can't run (Intel Macs, Windows, Linux without KVM), and for the images berth test and berth deploy build |
| 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. Root on the host can reach into the sandbox, whether it is a VM or a container (
docker exec), and bypass 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 | How sandboxes run in a microVM, and what the VM covers |
| 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.