-
-# ```Capsule``` Bash
-
+
+
+
+
+
-This project is currently under active development.
-To learn more about the underlying technology it is based on, check out [Capsule](https://github.com/capsulerun/capsule).
+# `Capsule` Bash
+
+**Sandboxed bash made for agents**
+
+
+
+
+## Quick Start
+
+```bash
+npm install @capsule-run/bash @capsule-run/bash-wasm
+```
+
+### TypeScript SDK
+
+```typescript
+import { Bash } from "@capsule-run/bash";
+import { WasmRuntime } from "@capsule-run/bash-wasm";
+
+const bash = new Bash({ runtime: new WasmRuntime() });
+
+const result = await bash.run("mkdir src && touch src/index.ts");
+
+console.log(result);
+/*
+{
+ stdout: "Folder created ✔\nFile created ✔",
+ stderr: "",
+ diff: { created: ['src', 'src/index.ts'], modified: [], deleted: [] },
+ duration: 10,
+ exitCode: 0,
+}
+*/
+```
+
+### MCP server
+
+```json
+{
+ "mcpServers": {
+ "capsule": {
+ "command": "npx",
+ "args": ["-y", "@capsule-run/bash-mcp"]
+ }
+ }
+}
+```
+
+See the [MCP README](packages/bash-mcp) for configuration details.
+
+### Interactive shell
+
+```bash
+pnpm -s bash-wasm-shell
+```
+
+> [!IMPORTANT]
+> Python and pip are required to compile the Python sandbox. Both sandboxes (JS and Python) are needed to run the shell.
+
+## How It Works
+
+Capsule Bash is built around three ideas:
+
+### Commands and sandboxes
+
+Bash commands are reimplemented in TypeScript and each one runs inside a sandbox, isolating the host system from anything the agent executes. The sandbox layer is modular. Plug in any runtime that implements the interface. The default `WasmRuntime` uses [Capsule](https://github.com/capsulerun/capsule) to run commands inside WebAssembly.
+
+> [!NOTE]
+> Not all bash commands are implemented yet. See packages/bash/src/commands/ for the current list.
+
+### Instant feedback
+
+Traditional bash treats silence as success. In an agentic context, that forces a second call just to confirm the first one worked. Capsule Bash returns structured output for every command. Exit code, stdout, stderr, and a diff of filesystem changes.
+
+### Workspace isolation
+
+The agent operates in a mounted workspace directory (`.capsule/session/workspace` by default). Your host filesystem does not exist from the agent's perspective. You get full visibility into what the agent does without exposing your system.
+
+## Limitations
+
+`WasmRuntime` runs in Node.js only. So, browser environments are not supported with the existing runtime yet.
+
+## Contributing
+
+Contributions are welcome, whether it's documentation, new commands, or bug reports.
+
+### Adding or improving commands
+
+Commands live in `packages/bash/src/commands/`. To contribute:
+
+1. Fork the repository
+2. Create a feature branch: `git checkout -b feature/amazing-command`
+3. Add or update your command in `packages/bash/src/commands/`
+4. Add unit tests
+5. Open a pull request
+
+## License
+
+Apache License 2.0. See [LICENSE](LICENSE) for details.
diff --git a/assets/example.gif b/assets/example.gif
new file mode 100644
index 0000000..0df2733
Binary files /dev/null and b/assets/example.gif differ
diff --git a/assets/logo-dark-mode.png b/assets/logo-dark-mode.png
new file mode 100644
index 0000000..2977d81
Binary files /dev/null and b/assets/logo-dark-mode.png differ
diff --git a/assets/logo-light-mode.png b/assets/logo-light-mode.png
new file mode 100644
index 0000000..f5f1054
Binary files /dev/null and b/assets/logo-light-mode.png differ
diff --git a/package.json b/package.json
index 04155b0..fb64d95 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"type": "commonjs",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "bash-wasm-shell": "pnpm --filter bash-wasm-shell bash-wasm-shell"
+ "bash-wasm-shell": "pnpm --filter bash-wasm-shell bash-wasm-shell",
+ "prebuild-wasm": "bash ./scripts/prebuild-wasm-sandboxes.sh"
},
"dependencies": {
"@capsule-run/cli": "^0.8.9",
diff --git a/packages/bash-mcp/README.md b/packages/bash-mcp/README.md
new file mode 100644
index 0000000..e0c80cb
--- /dev/null
+++ b/packages/bash-mcp/README.md
@@ -0,0 +1,61 @@
+# `@capsule-run/bash-mcp`
+
+[](https://github.com/capsule-run/bash/actions/workflows/mcp-integration-release.yml)
+
+An MCP server that gives your AI agent the ability to run bash commands in a secure, persistent, WebAssembly-sandboxed environment.
+
+## Tools
+
+| Tool | Description |
+|------|-------------|
+| `run` | Run a bash command in a sandboxed session. Returns stdout, stderr, exit code, filesystem diff, and current shell state (cwd + env). |
+| `reset` | Reset the filesystem and shell state (cwd, env vars) for a session back to their initial values. |
+| `sessions` | List all active shell sessions. |
+
+### Sessions
+
+Commands within the same `session_id` share cwd, environment variables, and filesystem state across calls. Each session maps to an isolated sandbox directory under `.capsule/sessions/`.
+
+### Example
+
+Ask your AI agent:
+
+> *"I have a log file — count how many errors occurred per hour."*
+
+The agent calls `run` sequentially:
+
+```json
+{ "command": "mkdir -p /data && echo 'ERROR 10:01\nINFO 10:02\nERROR 10:45\nERROR 11:03' > /data/app.log", "session_id": "analysis" }
+{ "command": "grep 'ERROR' /data/app.log | sed 's/ERROR \\([0-9]*\\):.*/\\1/' | sort | uniq -c", "session_id": "analysis" }
+```
+
+Each call returns `stdout`, `stderr`, `exitCode`, a filesystem `diff`, and the updated shell `state`.
+
+## Setup
+
+Add to your MCP client configuration (e.g. Claude Desktop, Cursor):
+
+```json
+{
+ "mcpServers": {
+ "bash": {
+ "command": "npx",
+ "args": ["-y", "@capsule-run/bash-mcp"]
+ }
+ }
+}
+```
+
+## How It Works
+
+Each session runs inside a WebAssembly sandbox powered by [`@capsule-run/bash-wasm`](https://github.com/capsule-run/bash). The sandbox provides:
+
+- **Persistent state** — cwd, env vars, and filesystem changes persist across commands within a session
+- **Filesystem diff** — every `run` response includes a diff of what changed on disk
+- **Isolated memory** — each session gets its own address space, no cross-session leakage
+- **No host access** — the sandbox cannot reach your host filesystem or network
+
+## Limitations
+
+- **Wasm bash** — runs a WASI-compiled bash; some advanced shell features or native binaries may not be available
+- **Session cleanup** — sessions live in memory and are lost when the server restarts; use `reset` to explicitly clear state
diff --git a/packages/bash-wasm-shell/src/bash.ts b/packages/bash-wasm-shell/src/bash.ts
index 7b60752..10ab8f3 100644
--- a/packages/bash-wasm-shell/src/bash.ts
+++ b/packages/bash-wasm-shell/src/bash.ts
@@ -4,3 +4,8 @@ import { WasmRuntime } from '@capsule-run/bash-wasm';
export const bash = new Bash({
runtime: new WasmRuntime(),
});
+
+export const preloadPromises = {
+ js: bash.preload("js").catch(() => {}),
+ python: bash.preload("python").catch(() => {}),
+};
diff --git a/packages/bash-wasm-shell/src/components/DiffTimeline.tsx b/packages/bash-wasm-shell/src/components/DiffTimeline.tsx
index 8e61bc6..0322609 100644
--- a/packages/bash-wasm-shell/src/components/DiffTimeline.tsx
+++ b/packages/bash-wasm-shell/src/components/DiffTimeline.tsx
@@ -94,10 +94,10 @@ export function DiffTimeline({ entry }: Props) {
{style.label}
{item.filenames?.map((filename, i) => (
-
+
{item.type === 'created' ? [+] : item.type === 'modified' ? [~] : [-]}
- {filename}
-
+ {filename}
+
))}
diff --git a/packages/bash-wasm-shell/src/components/Prompt.tsx b/packages/bash-wasm-shell/src/components/Prompt.tsx
index de9ee5a..148e42e 100644
--- a/packages/bash-wasm-shell/src/components/Prompt.tsx
+++ b/packages/bash-wasm-shell/src/components/Prompt.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
-import Spinner from 'ink-spinner';
+import type { HeredocState } from '../hooks/useShell.js';
type Props = {
cwd: string;
@@ -10,9 +10,10 @@ type Props = {
runningCommand: string;
onSubmit: (command: string) => void;
history: string[];
+ heredoc: HeredocState | null;
};
-export function Prompt({ cwd, lastExitCode, running, runningCommand, onSubmit, history }: Props) {
+export function Prompt({ cwd, lastExitCode, running, runningCommand, onSubmit, history, heredoc }: Props) {
const [input, setInput] = useState('');
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -42,6 +43,19 @@ export function Prompt({ cwd, lastExitCode, running, runningCommand, onSubmit, h
const promptColor = lastExitCode === 0 ? 'green' : 'red';
+ if (heredoc) {
+ return (
+
+ {'>'}
+ { setInput(val); }}
+ onSubmit={handleSubmit}
+ />
+
+ );
+ }
+
return (
{cwd != '/' ? cwd.slice(1) : cwd}
diff --git a/packages/bash-wasm-shell/src/components/Shell.tsx b/packages/bash-wasm-shell/src/components/Shell.tsx
index 0f4eccc..4c16e6d 100644
--- a/packages/bash-wasm-shell/src/components/Shell.tsx
+++ b/packages/bash-wasm-shell/src/components/Shell.tsx
@@ -5,7 +5,7 @@ import { OutputLine } from './OutputLine.js';
import { Prompt } from './Prompt.js';
export function Shell() {
- const { history, running, runningCommand, cwd, lastExitCode, submit, jsSandboxReady, pythonSandboxReady } = useShell();
+ const { history, running, runningCommand, cwd, lastExitCode, submit, jsSandboxReady, pythonSandboxReady, heredoc } = useShell();
const sandboxReady = jsSandboxReady && pythonSandboxReady;
const lastEntry = history[history.length - 1];
@@ -27,7 +27,7 @@ export function Shell() {
{sandboxReady && (
- h.command)} />
+ h.command)} heredoc={heredoc} />
)}
diff --git a/packages/bash-wasm-shell/src/hooks/useShell.ts b/packages/bash-wasm-shell/src/hooks/useShell.ts
index 8436486..6448b81 100644
--- a/packages/bash-wasm-shell/src/hooks/useShell.ts
+++ b/packages/bash-wasm-shell/src/hooks/useShell.ts
@@ -1,6 +1,6 @@
-import { useState, useCallback, useEffect } from 'react';
+import { useState, useCallback, useEffect, useRef } from 'react';
import type { CommandResult } from '@capsule-run/bash-types';
-import { bash } from '../bash.js';
+import { bash, preloadPromises } from '../bash.js';
export type HistoryEntry = {
command: string;
@@ -20,6 +20,12 @@ export type HistoryEntry = {
};
};
+export type HeredocState = {
+ command: string;
+ delimiter: string;
+ lines: string[];
+};
+
export function useShell() {
const [history, setHistory] = useState([]);
const [running, setRunning] = useState(false);
@@ -28,15 +34,40 @@ export function useShell() {
const [cwd, setCwd] = useState(bash.stateManager.state.cwd);
const [jsSandboxReady, setJsSandboxReady] = useState(false);
const [pythonSandboxReady, setPythonSandboxReady] = useState(false);
+ const [heredoc, setHeredoc] = useState(null);
+ const heredocRef = useRef(null);
useEffect(() => {
- bash.preload("js").then(() => setJsSandboxReady(true)).catch(() => {});
- bash.preload("python").then(() => setPythonSandboxReady(true)).catch(() => {});
+ preloadPromises.js.then(() => setJsSandboxReady(true));
+ preloadPromises.python.then(() => setPythonSandboxReady(true));
}, []);
const submit = useCallback(async (command: string) => {
if (!command.trim()) return;
+ const current = heredocRef.current;
+ if (current) {
+ if (command.trim() === current.delimiter) {
+ const fullCommand = `${current.command}\n${current.lines.join('\n')}\n${current.delimiter}`;
+ heredocRef.current = null;
+ setHeredoc(null);
+ await submit(fullCommand);
+ } else {
+ const updated = { ...current, lines: [...current.lines, command] };
+ heredocRef.current = updated;
+ setHeredoc(updated);
+ }
+ return;
+ }
+
+ const heredocMatch = !command.includes('\n') && command.match(/<<\s*'?(\w+)'?/);
+ if (heredocMatch) {
+ const next = { command, delimiter: heredocMatch[1], lines: [] };
+ heredocRef.current = next;
+ setHeredoc(next);
+ return;
+ }
+
if (command.trim() === 'clear') {
setHistory([]);
return;
@@ -81,5 +112,5 @@ export function useShell() {
}
}, []);
- return { history, running, runningCommand, lastExitCode, cwd, submit, jsSandboxReady, pythonSandboxReady };
+ return { history, running, runningCommand, lastExitCode, cwd, submit, jsSandboxReady, pythonSandboxReady, heredoc };
}
diff --git a/packages/bash-wasm/sandboxes/python/sandbox.py b/packages/bash-wasm/sandboxes/python/sandbox.py
index e6a5ef2..365cc72 100644
--- a/packages/bash-wasm/sandboxes/python/sandbox.py
+++ b/packages/bash-wasm/sandboxes/python/sandbox.py
@@ -25,12 +25,14 @@ def from_json(raw: str) -> "State":
def wasm_relative(cwd: str, file_path: str) -> str:
joined = os.path.normpath(os.path.join(cwd, file_path))
- return joined.lstrip("/")
+ return joined
@task(name="executeFile", compute="MEDIUM", ram="512MB", allowed_hosts=["*"])
def execute_file(state: str, file_path: str, args: list[str]):
state = State.from_json(state)
+ os.chdir(state.cwd)
+
rel_path = wasm_relative(state.cwd, file_path)
file_dir = os.path.dirname(rel_path) or "."
@@ -80,6 +82,7 @@ def execute_file(state: str, file_path: str, args: list[str]):
@task(name="executeCode", compute="LOW", ram="256MB", allowed_hosts=["*"])
def execute_code(state: str, code: str):
state = State.from_json(state)
+ os.chdir(state.cwd)
site_packages = os.path.join(state.cwd, "site-packages")
if site_packages not in sys.path:
diff --git a/packages/bash-wasm/src/runtime.ts b/packages/bash-wasm/src/runtime.ts
index b1848ac..611a986 100644
--- a/packages/bash-wasm/src/runtime.ts
+++ b/packages/bash-wasm/src/runtime.ts
@@ -17,8 +17,8 @@ export class WasmRuntime implements BaseRuntime {
public hostWorkspace: string = "";
constructor() {
- const jsWasmPath = path.resolve(__dirname, "../dist/sandboxes/js/sandbox.wasm");
- const pyWasmPath = path.resolve(__dirname, "../dist/sandboxes/python/sandbox.wasm");
+ const jsWasmPath = path.resolve(__dirname, "../dist/sandboxes/js-sandbox.wasm");
+ const pyWasmPath = path.resolve(__dirname, "../dist/sandboxes/python-sandbox.wasm");
if (fs.existsSync(jsWasmPath) && fs.existsSync(pyWasmPath)) {
this.jsSandbox = jsWasmPath;
diff --git a/packages/bash/src/commands/cat/cat.handler.ts b/packages/bash/src/commands/cat/cat.handler.ts
index 4121803..213a9e1 100644
--- a/packages/bash/src/commands/cat/cat.handler.ts
+++ b/packages/bash/src/commands/cat/cat.handler.ts
@@ -6,11 +6,15 @@ export const manual: CommandManual = {
usage: "cat [file]"
};
-export const handler: CommandHandler = async ({ opts, state, runtime }: CommandContext) => {
+export const handler: CommandHandler = async ({ opts, stdin, state, runtime }: CommandContext) => {
const stderr: string[] = [];
const stdout: string[] = [];
+ if (opts.args.length === 0) {
+ return { stdout: stdin, stderr: '', exitCode: 0 };
+ }
+
await Promise.all(opts.args.map(async (arg) => {
const destinationAbsolutePath = await runtime.resolvePath(state, arg)
diff --git a/packages/bash/src/core/executor.ts b/packages/bash/src/core/executor.ts
index 5736ffd..fdc449d 100644
--- a/packages/bash/src/core/executor.ts
+++ b/packages/bash/src/core/executor.ts
@@ -24,10 +24,8 @@ export class Executor {
private readonly state: State,
) {}
- private snapshotFs(): FsSnapshot {
+ private snapshotFs(root: string): FsSnapshot {
const snapshot: FsSnapshot = {};
- const workspace = this.runtime.hostWorkspace;
- if (!workspace) return snapshot;
const walk = (dir: string) => {
try {
@@ -35,17 +33,28 @@ export class Executor {
const fullPath = path.join(dir, entry);
try {
const stat = fs.statSync(fullPath);
- if (stat.isDirectory()) walk(fullPath);
- else snapshot[fullPath.slice(workspace.length)] = stat.mtimeMs;
+ if (stat.isDirectory()) {
+ snapshot[fullPath.slice(root.length + 1) + '/'] = stat.mtimeMs;
+ walk(fullPath);
+ } else {
+ snapshot[fullPath.slice(root.length + 1)] = stat.mtimeMs;
+ }
} catch {}
}
} catch {}
};
- walk(workspace);
+ walk(root);
return snapshot;
}
+ private cwdRoot(): string {
+ const workspace = this.runtime.hostWorkspace;
+ if (!workspace) return '';
+ const relativeCwd = this.state.cwd.replace(/^\//, '');
+ return relativeCwd ? path.join(workspace, relativeCwd) : workspace;
+ }
+
private diffSnapshots(before: FsSnapshot, after: FsSnapshot): { created: string[]; modified: string[]; deleted: string[] } {
const created: string[] = [];
const modified: string[] = [];
@@ -130,6 +139,11 @@ export class Executor {
}
for (const r of node.redirects) {
+ if (r.op === '<<') {
+ stdin = r.body;
+ continue;
+ }
+
if (r.op === '<') {
if (r.file === '/dev/null') {
stdin = '';
@@ -151,7 +165,8 @@ export class Executor {
const opts = parsedCommandOptions(args);
const command = await this.searchCommandHandler(name);
- const before = this.snapshotFs();
+ const root = this.cwdRoot();
+ const before = this.snapshotFs(root);
if (!command) {
result = { stdout: '', stderr: `bash: ${name}: command not found`, exitCode: 127, durationMs: Date.now() - start };
@@ -242,7 +257,7 @@ export class Executor {
}
}
- const after = this.snapshotFs();
+ const after = this.snapshotFs(root);
const diff = this.diffSnapshots(before, after);
const durationMs = Date.now() - start;
diff --git a/packages/bash/src/core/filesystem.ts b/packages/bash/src/core/filesystem.ts
index 5029918..708a616 100644
--- a/packages/bash/src/core/filesystem.ts
+++ b/packages/bash/src/core/filesystem.ts
@@ -1,5 +1,9 @@
import fs from 'fs';
import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
export class Filesystem {
constructor(private readonly workspace: string) {}
@@ -11,12 +15,14 @@ export class Filesystem {
fs.mkdirSync(path.join(this.workspace, dir), { recursive: true });
}
+ const availableCommandsList = fs.readdirSync(path.resolve(__dirname, '../commands')).join('\n').trim();
+
const files: Record = {
'etc/resolv.conf': 'nameserver 8.8.8.8\nnameserver 1.1.1.1\n',
'etc/os-release': 'NAME="Capsule OS"\nVERSION="1.0"\nID=capsule\n',
'etc/passwd': 'root:x:0:0:root:/root:/bin/bash\n',
'proc/cpuinfo': 'processor\t: 0\nvendor_id\t: CapsuleVirtualCPU\n',
- 'workspace/README.md': '# Welcome to the Capsule Bash Environment\n\nYou are operating inside a secure and minimalist sandboxed bash.'
+ 'workspace/README.md': `You are operating inside a sandboxed bash. Here the list of available commands:\n${availableCommandsList}`
};
for (const [relativePath, content] of Object.entries(files)) {
diff --git a/packages/bash/src/core/parser.ts b/packages/bash/src/core/parser.ts
index aa1f103..c803140 100644
--- a/packages/bash/src/core/parser.ts
+++ b/packages/bash/src/core/parser.ts
@@ -7,13 +7,18 @@ export type FileRedirect = {
file: string;
};
+export type HereDocRedirect = {
+ op: '<<';
+ body: string;
+};
+
export type FdRedirect = {
op: '>&';
from: number;
to: number;
};
-export type Redirect = FileRedirect | FdRedirect;
+export type Redirect = FileRedirect | FdRedirect | HereDocRedirect;
export type CommandNode = {
type: 'command';
@@ -61,15 +66,45 @@ function tokenToString(token: ShellToken): string | null {
export class Parser {
private tokens: ShellToken[] = [];
private pos = 0;
+ private heredocs: Map = new Map();
parse(input: string): ASTNode {
- this.tokens = (shellQuote.parse(input) as ShellToken[]).filter(
+ this.heredocs = new Map();
+ const processed = this.extractHeredocs(input);
+ this.tokens = (shellQuote.parse(processed) as ShellToken[]).filter(
(t) => !(typeof t === 'object' && 'comment' in t)
);
this.pos = 0;
return this.parseSequence();
}
+ private extractHeredocs(input: string): string {
+ const lines = input.split('\n');
+ let result = '';
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ const match = line.match(/^(.*?)<<\s*'?(\w+)'?\s*(.*)$/);
+ if (match) {
+ const [, before, delimiter, after] = match;
+ const key = `__HEREDOC_${this.heredocs.size}__`;
+ const bodyLines: string[] = [];
+ i++;
+ while (i < lines.length && lines[i].trim() !== delimiter) {
+ bodyLines.push(lines[i]);
+ i++;
+ }
+ this.heredocs.set(key, bodyLines.join('\n'));
+ // Use < instead of << since shell-quote doesn't tokenize << as one op
+ result += `${before}< ${key}${after ? ' ' + after : ''}\n`;
+ } else {
+ result += line + '\n';
+ }
+ i++;
+ }
+ return result.trimEnd();
+ }
+
private peek(): ShellToken | undefined {
return this.tokens[this.pos];
}
@@ -142,7 +177,11 @@ export class Parser {
if (file === null) throw new SyntaxError(`Expected filename after '${(token as { op: string }).op}'`);
- redirects.push({ op: (token as { op: RedirectOp }).op, file });
+ if (isOp(token, '<') && this.heredocs.has(file)) {
+ redirects.push({ op: '<<', body: this.heredocs.get(file)! });
+ } else {
+ redirects.push({ op: (token as { op: RedirectOp }).op, file });
+ }
} else if (isOp(token)) {
break;
} else {
diff --git a/scripts/prebuild-wasm-sandboxes.sh b/scripts/prebuild-wasm-sandboxes.sh
new file mode 100644
index 0000000..6d740cf
--- /dev/null
+++ b/scripts/prebuild-wasm-sandboxes.sh
@@ -0,0 +1,11 @@
+pnpm install
+
+pip install -r packages/bash-wasm/sandboxes/python/requirements.txt
+
+capsule build packages/bash-wasm/sandboxes/js/sandbox.ts --export
+mkdir -p packages/bash-wasm/dist/sandboxes
+mv packages/bash-wasm/sandboxes/js/sandbox.wasm packages/bash-wasm/dist/sandboxes/js-sandbox.wasm
+
+capsule build packages/bash-wasm/sandboxes/python/sandbox.py --export
+mkdir -p packages/bash-wasm/dist/sandboxes
+mv packages/bash-wasm/sandboxes/python/sandbox.wasm packages/bash-wasm/dist/sandboxes/python-sandbox.wasm