mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +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
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue