Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ vite.config.ts.timestamp-*
# DS Store
.DS_Store

# python
__pycache__/


# Capsule
.capsule/
Expand Down
109 changes: 104 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,110 @@
<div align="center">

# ```Capsule``` Bash

![WIP](https://img.shields.io/badge/status-WIP-orange)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark-mode.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light-mode.png" />
<img alt="Capsule Bash" src="assets/logo-light-mode.png" width="80" />
</picture>

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**

![CI](https://github.com/capsulerun/bash/actions/workflows/ci.yml/badge.svg)

![Example Shell](assets/example.gif)
</div>

## 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.

Binary file added assets/example.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/logo-dark-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/logo-light-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions packages/bash-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# `@capsule-run/bash-mcp`

[![MCP Server Release](https://github.com/capsule-run/bash/actions/workflows/mcp-integration-release.yml/badge.svg)](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/<session_id>`.

### 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
5 changes: 5 additions & 0 deletions packages/bash-wasm-shell/src/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {}),
};
6 changes: 3 additions & 3 deletions packages/bash-wasm-shell/src/components/DiffTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ export function DiffTimeline({ entry }: Props) {
<Text bold>{style.label}</Text>
<Box flexDirection="column">
{item.filenames?.map((filename, i) => (
<Text key={i}>
<Box key={i} flexDirection="row" gap={1}>
{item.type === 'created' ? <Text color={style.color}>[+]</Text> : item.type === 'modified' ? <Text color={style.color}>[~]</Text> : <Text color={style.color}>[-]</Text>}
{filename}
</Text>
<Text>{filename}</Text>
</Box>
))}
</Box>
</Box>
Expand Down
18 changes: 16 additions & 2 deletions packages/bash-wasm-shell/src/components/Prompt.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -42,6 +43,19 @@ export function Prompt({ cwd, lastExitCode, running, runningCommand, onSubmit, h

const promptColor = lastExitCode === 0 ? 'green' : 'red';

if (heredoc) {
return (
<Box gap={1} marginLeft={1}>
<Text bold color="yellow">{'>'}</Text>
<TextInput
value={input}
onChange={(val) => { setInput(val); }}
onSubmit={handleSubmit}
/>
</Box>
);
}

return (
<Box gap={1} marginLeft={1}>
<Text bold>{cwd != '/' ? cwd.slice(1) : cwd}</Text>
Expand Down
4 changes: 2 additions & 2 deletions packages/bash-wasm-shell/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -27,7 +27,7 @@ export function Shell() {
</Static>

{sandboxReady && (
<Prompt cwd={cwd} lastExitCode={lastExitCode} running={running} runningCommand={runningCommand} onSubmit={submit} history={history.map(h => h.command)} />
<Prompt cwd={cwd} lastExitCode={lastExitCode} running={running} runningCommand={runningCommand} onSubmit={submit} history={history.map(h => h.command)} heredoc={heredoc} />
)}
</Box>
</Box>
Expand Down
41 changes: 36 additions & 5 deletions packages/bash-wasm-shell/src/hooks/useShell.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,6 +20,12 @@ export type HistoryEntry = {
};
};

export type HeredocState = {
command: string;
delimiter: string;
lines: string[];
};

export function useShell() {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [running, setRunning] = useState(false);
Expand All @@ -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<HeredocState | null>(null);
const heredocRef = useRef<HeredocState | null>(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;
Expand Down Expand Up @@ -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 };
}
5 changes: 4 additions & 1 deletion packages/bash-wasm/sandboxes/python/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "."

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/bash-wasm/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading