diff --git a/.gitignore b/.gitignore index 14b8893..fed0509 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,9 @@ vite.config.ts.timestamp-* # DS Store .DS_Store +# python +__pycache__/ + # Capsule .capsule/ diff --git a/README.md b/README.md index 06cd464..14a4c50 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,110 @@
- -# ```Capsule``` Bash -![WIP](https://img.shields.io/badge/status-WIP-orange) + + + + 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** + +![CI](https://github.com/capsulerun/bash/actions/workflows/ci.yml/badge.svg) + +![Example Shell](assets/example.gif)
+## 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` + +[![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/`. + +### 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