Skip to content

Commit e486f53

Browse files
committed
Merge remote-tracking branch 'origin/cc-sandbox' into tool-governor
2 parents 9ce653e + 968ed10 commit e486f53

15 files changed

Lines changed: 999 additions & 1 deletion
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"id": "edgeclaw-sandbox",
3+
"name": "EdgeClaw OS Sandbox",
4+
"description": "OS-level sandbox backend (bwrap/sandbox-exec) for EdgeClaw agents, powered by @anthropic-ai/sandbox-runtime.",
5+
"version": "0.1.0",
6+
"kind": "sandbox",
7+
"configSchema": {
8+
"type": "object",
9+
"additionalProperties": false,
10+
"properties": {
11+
"network": {
12+
"type": "object",
13+
"additionalProperties": false,
14+
"properties": {
15+
"allowedDomains": {
16+
"type": "array",
17+
"items": { "type": "string" },
18+
"description": "Domain allowlist for outbound network access."
19+
},
20+
"deniedDomains": {
21+
"type": "array",
22+
"items": { "type": "string" },
23+
"description": "Domain denylist for outbound network access."
24+
},
25+
"allowUnixSockets": {
26+
"type": "array",
27+
"items": { "type": "string" },
28+
"description": "Unix socket paths to allow."
29+
},
30+
"allowAllUnixSockets": {
31+
"type": "boolean",
32+
"description": "Allow all Unix socket connections."
33+
},
34+
"allowLocalBinding": {
35+
"type": "boolean",
36+
"description": "Allow binding to localhost ports."
37+
}
38+
}
39+
},
40+
"filesystem": {
41+
"type": "object",
42+
"additionalProperties": false,
43+
"properties": {
44+
"allowWrite": {
45+
"type": "array",
46+
"items": { "type": "string" },
47+
"description": "Additional paths to allow writing (workspace is always included)."
48+
},
49+
"denyWrite": {
50+
"type": "array",
51+
"items": { "type": "string" },
52+
"description": "Paths to deny writing."
53+
},
54+
"allowRead": {
55+
"type": "array",
56+
"items": { "type": "string" },
57+
"description": "Additional paths to allow reading."
58+
},
59+
"denyRead": {
60+
"type": "array",
61+
"items": { "type": "string" },
62+
"description": "Paths to deny reading."
63+
}
64+
}
65+
}
66+
}
67+
},
68+
"uiHints": {
69+
"network.allowedDomains": {
70+
"label": "Allowed Domains",
71+
"placeholder": "registry.npmjs.org, pypi.org"
72+
},
73+
"filesystem.denyWrite": {
74+
"label": "Deny Write Paths",
75+
"placeholder": "~/.ssh, ~/.gnupg"
76+
}
77+
}
78+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "@openclaw/edgeclaw-sandbox",
3+
"version": "2026.4.1",
4+
"private": true,
5+
"description": "OS-level sandbox backend (bwrap/sandbox-exec) for EdgeClaw agents.",
6+
"type": "module",
7+
"dependencies": {
8+
"@anthropic-ai/sandbox-runtime": "^0.0.46"
9+
},
10+
"openclaw": {
11+
"extensions": [
12+
"./src/index.ts"
13+
]
14+
}
15+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { spawn } from "node:child_process";
2+
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
3+
import type {
4+
CreateSandboxBackendParams,
5+
SandboxBackendCommandParams,
6+
SandboxBackendCommandResult,
7+
SandboxBackendHandle,
8+
SandboxBackendManager,
9+
} from "openclaw/plugin-sdk/sandbox";
10+
import { mapToSandboxRuntimeConfig, type EdgeClawSandboxPluginConfig } from "./config.js";
11+
12+
let initialized = false;
13+
14+
async function ensureInitialized(config: SandboxRuntimeConfig): Promise<void> {
15+
if (initialized) {
16+
SandboxManager.updateConfig(config);
17+
return;
18+
}
19+
await SandboxManager.initialize(config);
20+
initialized = true;
21+
}
22+
23+
function runWrappedCommand(
24+
wrappedCommand: string,
25+
params: {
26+
env: Record<string, string>;
27+
stdin?: Buffer | string;
28+
allowFailure?: boolean;
29+
signal?: AbortSignal;
30+
},
31+
): Promise<SandboxBackendCommandResult> {
32+
return new Promise<SandboxBackendCommandResult>((resolve, reject) => {
33+
const child = spawn("/bin/sh", ["-c", wrappedCommand], {
34+
stdio: ["pipe", "pipe", "pipe"],
35+
env: { ...process.env, ...params.env },
36+
signal: params.signal,
37+
});
38+
39+
const stdoutChunks: Buffer[] = [];
40+
const stderrChunks: Buffer[] = [];
41+
42+
child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
43+
child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
44+
child.on("error", reject);
45+
child.on("close", (code) => {
46+
const stdout = Buffer.concat(stdoutChunks);
47+
const stderr = Buffer.concat(stderrChunks);
48+
const exitCode = code ?? 0;
49+
if (exitCode !== 0 && !params.allowFailure) {
50+
reject(
51+
Object.assign(
52+
new Error(`Sandbox command failed (exit ${exitCode}): ${stderr.toString("utf8")}`),
53+
{ code: exitCode, stdout, stderr },
54+
),
55+
);
56+
return;
57+
}
58+
resolve({ stdout, stderr, code: exitCode });
59+
});
60+
61+
if (params.stdin !== undefined) {
62+
child.stdin.end(params.stdin);
63+
} else {
64+
child.stdin.end();
65+
}
66+
});
67+
}
68+
69+
export function createBwrapSandboxBackendFactory(
70+
getPluginConfig: () => EdgeClawSandboxPluginConfig,
71+
) {
72+
const factory = async (params: CreateSandboxBackendParams): Promise<SandboxBackendHandle> => {
73+
const pluginConfig = getPluginConfig();
74+
const runtimeConfig = mapToSandboxRuntimeConfig(
75+
pluginConfig,
76+
params.workspaceDir,
77+
params.agentWorkspaceDir,
78+
);
79+
await ensureInitialized(runtimeConfig);
80+
81+
return {
82+
id: "bwrap",
83+
runtimeId: `bwrap-${params.scopeKey}`,
84+
runtimeLabel: `OS Sandbox (${params.scopeKey})`,
85+
workdir: params.workspaceDir,
86+
env: params.cfg.docker.env,
87+
88+
async buildExecSpec({ command, workdir, env, usePty }) {
89+
const wrapped = await SandboxManager.wrapWithSandbox(command);
90+
return {
91+
argv: ["/bin/sh", "-c", wrapped],
92+
env: { ...process.env, ...env },
93+
stdinMode: usePty ? ("pipe-open" as const) : ("pipe-closed" as const),
94+
};
95+
},
96+
97+
async runShellCommand(command: SandboxBackendCommandParams) {
98+
const wrapped = await SandboxManager.wrapWithSandbox(command.script);
99+
return runWrappedCommand(wrapped, {
100+
env: {},
101+
stdin: command.stdin,
102+
allowFailure: command.allowFailure,
103+
signal: command.signal,
104+
});
105+
},
106+
};
107+
};
108+
109+
return factory;
110+
}
111+
112+
export const bwrapSandboxBackendManager: SandboxBackendManager = {
113+
async describeRuntime() {
114+
return {
115+
running: SandboxManager.isSupportedPlatform(),
116+
configLabelMatch: true,
117+
};
118+
},
119+
async removeRuntime() {
120+
// bwrap has no persistent runtime to remove
121+
},
122+
};
123+
124+
/**
125+
* Reset internal initialization state.
126+
* Exposed for testing only.
127+
*/
128+
export function resetBwrapState(): void {
129+
initialized = false;
130+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
2+
3+
export type EdgeClawSandboxNetworkConfig = {
4+
allowedDomains?: string[];
5+
deniedDomains?: string[];
6+
allowUnixSockets?: string[];
7+
allowAllUnixSockets?: boolean;
8+
allowLocalBinding?: boolean;
9+
};
10+
11+
export type EdgeClawSandboxFilesystemConfig = {
12+
allowWrite?: string[];
13+
denyWrite?: string[];
14+
allowRead?: string[];
15+
denyRead?: string[];
16+
};
17+
18+
export type EdgeClawSandboxPluginConfig = {
19+
network?: EdgeClawSandboxNetworkConfig;
20+
filesystem?: EdgeClawSandboxFilesystemConfig;
21+
};
22+
23+
/**
24+
* Convert EdgeClaw plugin config + backend params into the
25+
* SandboxRuntimeConfig shape that @anthropic-ai/sandbox-runtime expects.
26+
*
27+
* The workspace directory is always added to allowWrite so sandboxed
28+
* commands can operate on the agent's working tree.
29+
*/
30+
export function mapToSandboxRuntimeConfig(
31+
pluginConfig: EdgeClawSandboxPluginConfig,
32+
workspaceDir: string,
33+
agentWorkspaceDir?: string,
34+
): SandboxRuntimeConfig {
35+
const extraWritePaths = pluginConfig.filesystem?.allowWrite ?? [];
36+
const allowWrite = [workspaceDir, ...extraWritePaths];
37+
if (agentWorkspaceDir && agentWorkspaceDir !== workspaceDir) {
38+
allowWrite.push(agentWorkspaceDir);
39+
}
40+
41+
return {
42+
network: {
43+
allowedDomains: pluginConfig.network?.allowedDomains ?? [],
44+
deniedDomains: pluginConfig.network?.deniedDomains ?? [],
45+
allowUnixSockets: pluginConfig.network?.allowUnixSockets,
46+
allowAllUnixSockets: pluginConfig.network?.allowAllUnixSockets,
47+
allowLocalBinding: pluginConfig.network?.allowLocalBinding,
48+
},
49+
filesystem: {
50+
allowWrite,
51+
denyWrite: pluginConfig.filesystem?.denyWrite ?? [],
52+
allowRead: pluginConfig.filesystem?.allowRead ?? [],
53+
denyRead: pluginConfig.filesystem?.denyRead ?? [],
54+
},
55+
};
56+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import type {
4+
SandboxFsBridge,
5+
SandboxFsStat,
6+
SandboxResolvedPath,
7+
} from "openclaw/plugin-sdk/sandbox";
8+
9+
/**
10+
* Pass-through fsBridge for the bwrap backend.
11+
*
12+
* Since bwrap runs on the host filesystem (unlike Docker which has an
13+
* isolated rootfs), all paths map to themselves — no docker-cp or
14+
* remote-shell indirection is needed.
15+
*/
16+
export function createBwrapFsBridge(params: {
17+
workspaceDir: string;
18+
containerWorkdir: string;
19+
}): SandboxFsBridge {
20+
const { workspaceDir, containerWorkdir } = params;
21+
22+
function resolveAbsolute(filePath: string, cwd?: string): string {
23+
if (path.isAbsolute(filePath)) {
24+
return filePath;
25+
}
26+
return path.resolve(cwd ?? workspaceDir, filePath);
27+
}
28+
29+
return {
30+
resolvePath(p: { filePath: string; cwd?: string }): SandboxResolvedPath {
31+
const abs = resolveAbsolute(p.filePath, p.cwd);
32+
const rel = path.relative(workspaceDir, abs);
33+
return {
34+
hostPath: abs,
35+
relativePath: rel,
36+
containerPath: path.join(containerWorkdir, rel),
37+
};
38+
},
39+
40+
async readFile(p: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<Buffer> {
41+
const abs = resolveAbsolute(p.filePath, p.cwd);
42+
return fs.readFile(abs, { signal: p.signal });
43+
},
44+
45+
async writeFile(p: {
46+
filePath: string;
47+
cwd?: string;
48+
data: Buffer | string;
49+
encoding?: BufferEncoding;
50+
mkdir?: boolean;
51+
signal?: AbortSignal;
52+
}): Promise<void> {
53+
const abs = resolveAbsolute(p.filePath, p.cwd);
54+
if (p.mkdir) {
55+
await fs.mkdir(path.dirname(abs), { recursive: true });
56+
}
57+
await fs.writeFile(abs, p.data, {
58+
encoding: p.encoding,
59+
signal: p.signal,
60+
});
61+
},
62+
63+
async mkdirp(p: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void> {
64+
const abs = resolveAbsolute(p.filePath, p.cwd);
65+
await fs.mkdir(abs, { recursive: true });
66+
},
67+
68+
async remove(p: {
69+
filePath: string;
70+
cwd?: string;
71+
recursive?: boolean;
72+
force?: boolean;
73+
signal?: AbortSignal;
74+
}): Promise<void> {
75+
const abs = resolveAbsolute(p.filePath, p.cwd);
76+
await fs.rm(abs, { recursive: p.recursive ?? false, force: p.force ?? false });
77+
},
78+
79+
async rename(p: {
80+
from: string;
81+
to: string;
82+
cwd?: string;
83+
signal?: AbortSignal;
84+
}): Promise<void> {
85+
const absFrom = resolveAbsolute(p.from, p.cwd);
86+
const absTo = resolveAbsolute(p.to, p.cwd);
87+
await fs.rename(absFrom, absTo);
88+
},
89+
90+
async stat(p: {
91+
filePath: string;
92+
cwd?: string;
93+
signal?: AbortSignal;
94+
}): Promise<SandboxFsStat | null> {
95+
const abs = resolveAbsolute(p.filePath, p.cwd);
96+
try {
97+
const s = await fs.stat(abs);
98+
return {
99+
type: s.isDirectory() ? "directory" : s.isFile() ? "file" : "other",
100+
size: s.size,
101+
mtimeMs: s.mtimeMs,
102+
};
103+
} catch {
104+
return null;
105+
}
106+
},
107+
};
108+
}

0 commit comments

Comments
 (0)