mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
feat(extensions): add sandbox extension for OS-level bash sandboxing (#673)
Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network restrictions on bash commands (sandbox-exec on macOS, bubblewrap on Linux). Features: - Per-project config via .pi/sandbox.json - Global config via ~/.pi/agent/sandbox.json - Enabled by default with sensible defaults - --no-sandbox flag to disable - /sandbox command to view current config
This commit is contained in:
parent
7b79e8ec51
commit
4751ebddbd
5 changed files with 431 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
||||
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
|
||||
|
||||
### Custom Tools
|
||||
|
||||
|
|
|
|||
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Sandbox Extension - OS-level sandboxing for bash commands
|
||||
*
|
||||
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
|
||||
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
|
||||
* bubblewrap on Linux).
|
||||
*
|
||||
* Config files (merged, project takes precedence):
|
||||
* - ~/.pi/agent/sandbox.json (global)
|
||||
* - <cwd>/.pi/sandbox.json (project-local)
|
||||
*
|
||||
* Example .pi/sandbox.json:
|
||||
* ```json
|
||||
* {
|
||||
* "enabled": true,
|
||||
* "network": {
|
||||
* "allowedDomains": ["github.com", "*.github.com"],
|
||||
* "deniedDomains": []
|
||||
* },
|
||||
* "filesystem": {
|
||||
* "denyRead": ["~/.ssh", "~/.aws"],
|
||||
* "allowWrite": [".", "/tmp"],
|
||||
* "denyWrite": [".env"]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usage:
|
||||
* - `pi -e ./sandbox` - sandbox enabled with default/config settings
|
||||
* - `pi -e ./sandbox --no-sandbox` - disable sandboxing
|
||||
* - `/sandbox` - show current sandbox configuration
|
||||
*
|
||||
* Setup:
|
||||
* 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
|
||||
* 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/
|
||||
*
|
||||
* Linux also requires: bubblewrap, socat, ripgrep
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
interface SandboxConfig extends SandboxRuntimeConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SandboxConfig = {
|
||||
enabled: true,
|
||||
network: {
|
||||
allowedDomains: [
|
||||
"npmjs.org",
|
||||
"*.npmjs.org",
|
||||
"registry.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"pypi.org",
|
||||
"*.pypi.org",
|
||||
"github.com",
|
||||
"*.github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
],
|
||||
deniedDomains: [],
|
||||
},
|
||||
filesystem: {
|
||||
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
|
||||
allowWrite: [".", "/tmp"],
|
||||
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
|
||||
},
|
||||
};
|
||||
|
||||
function loadConfig(cwd: string): SandboxConfig {
|
||||
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
|
||||
const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
|
||||
|
||||
let globalConfig: Partial<SandboxConfig> = {};
|
||||
let projectConfig: Partial<SandboxConfig> = {};
|
||||
|
||||
if (existsSync(globalConfigPath)) {
|
||||
try {
|
||||
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(projectConfigPath)) {
|
||||
try {
|
||||
projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
||||
}
|
||||
|
||||
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
||||
const result: SandboxConfig = { ...base };
|
||||
|
||||
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
|
||||
if (overrides.network) {
|
||||
result.network = { ...base.network, ...overrides.network };
|
||||
}
|
||||
if (overrides.filesystem) {
|
||||
result.filesystem = { ...base.filesystem, ...overrides.filesystem };
|
||||
}
|
||||
|
||||
const extOverrides = overrides as {
|
||||
ignoreViolations?: Record<string, string[]>;
|
||||
enableWeakerNestedSandbox?: boolean;
|
||||
};
|
||||
const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
|
||||
|
||||
if (extOverrides.ignoreViolations) {
|
||||
extResult.ignoreViolations = extOverrides.ignoreViolations;
|
||||
}
|
||||
if (extOverrides.enableWeakerNestedSandbox !== undefined) {
|
||||
extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function createSandboxedBashOps(): BashOperations {
|
||||
return {
|
||||
async exec(command, cwd, { onData, signal, timeout }) {
|
||||
if (!existsSync(cwd)) {
|
||||
throw new Error(`Working directory does not exist: ${cwd}`);
|
||||
}
|
||||
|
||||
const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("bash", ["-c", wrappedCommand], {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
}, timeout * 1000);
|
||||
}
|
||||
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", onData);
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
} else if (timedOut) {
|
||||
reject(new Error(`timeout:${timeout}`));
|
||||
} else {
|
||||
resolve({ exitCode: code });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerFlag("no-sandbox", {
|
||||
description: "Disable OS-level sandboxing for bash commands",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const localCwd = process.cwd();
|
||||
const localBash = createBashTool(localCwd);
|
||||
|
||||
let sandboxEnabled = false;
|
||||
let sandboxInitialized = false;
|
||||
|
||||
pi.registerTool({
|
||||
...localBash,
|
||||
label: "bash (sandboxed)",
|
||||
async execute(id, params, onUpdate, ctx, signal) {
|
||||
if (!sandboxEnabled || !sandboxInitialized) {
|
||||
return localBash.execute(id, params, signal, onUpdate);
|
||||
}
|
||||
|
||||
const sandboxedBash = createBashTool(localCwd, {
|
||||
operations: createSandboxedBashOps(),
|
||||
});
|
||||
return sandboxedBash.execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("user_bash", () => {
|
||||
if (!sandboxEnabled || !sandboxInitialized) return;
|
||||
return { operations: createSandboxedBashOps() };
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const noSandbox = pi.getFlag("no-sandbox") as boolean;
|
||||
|
||||
if (noSandbox) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(ctx.cwd);
|
||||
|
||||
if (!config.enabled) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify("Sandbox disabled via config", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
if (platform !== "darwin" && platform !== "linux") {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const configExt = config as unknown as {
|
||||
ignoreViolations?: Record<string, string[]>;
|
||||
enableWeakerNestedSandbox?: boolean;
|
||||
};
|
||||
|
||||
await SandboxManager.initialize({
|
||||
network: config.network,
|
||||
filesystem: config.filesystem,
|
||||
ignoreViolations: configExt.ignoreViolations,
|
||||
enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
|
||||
});
|
||||
|
||||
sandboxEnabled = true;
|
||||
sandboxInitialized = true;
|
||||
|
||||
const networkCount = config.network?.allowedDomains?.length ?? 0;
|
||||
const writeCount = config.filesystem?.allowWrite?.length ?? 0;
|
||||
ctx.ui.setStatus(
|
||||
"sandbox",
|
||||
ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`),
|
||||
);
|
||||
ctx.ui.notify("Sandbox initialized", "info");
|
||||
} catch (err) {
|
||||
sandboxEnabled = false;
|
||||
ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (sandboxInitialized) {
|
||||
try {
|
||||
await SandboxManager.reset();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("sandbox", {
|
||||
description: "Show sandbox configuration",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!sandboxEnabled) {
|
||||
ctx.ui.notify("Sandbox is disabled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig(ctx.cwd);
|
||||
const lines = [
|
||||
"Sandbox Configuration:",
|
||||
"",
|
||||
"Network:",
|
||||
` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
|
||||
` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
|
||||
"",
|
||||
"Filesystem:",
|
||||
` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
|
||||
` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
|
||||
` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
|
||||
];
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
||||
"version": "0.0.26",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz",
|
||||
"integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@pondwader/socks5-server": "^1.0.10",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"commander": "^12.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"shell-quote": "^1.8.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
"srt": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pondwader/socks5-server": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
"build": "echo 'nothing to build'",
|
||||
"check": "echo 'nothing to check'"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue