Skip to content

Commit b8ad654

Browse files
shreyas-lyzrclaude
andcommitted
Add sandbox mode with gitmachine integration
Adds optional sandbox mode where agent tool execution runs inside an isolated E2B cloud VM via the gitmachine package. The agent (LLM calls) still runs locally — only tool execution is remote. All changes are automatically committed to a session branch. New files: - src/tools/shared.ts: extracted shared constants, schemas, helpers - src/sandbox.ts: SandboxConfig/SandboxContext with dynamic import - src/tools/sandbox-{cli,read,write,memory}.ts: sandbox tool variants - src/tools/index.ts: createBuiltinTools() factory CLI: gitclaw --sandbox (requires E2B_API_KEY + GITHUB_TOKEN) SDK: query({ sandbox: true }) or query({ sandbox: { provider: "e2b" } }) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 825f25c commit b8ad654

16 files changed

Lines changed: 626 additions & 92 deletions

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "A universal git-native agent powered by pi-agent-core",
55
"type": "module",
66
"main": "./dist/exports.js",
@@ -32,6 +32,14 @@
3232
"@sinclair/typebox": "^0.34.41",
3333
"js-yaml": "^4.1.0"
3434
},
35+
"peerDependencies": {
36+
"gitmachine": ">=0.1.0"
37+
},
38+
"peerDependenciesMeta": {
39+
"gitmachine": {
40+
"optional": true
41+
}
42+
},
3543
"devDependencies": {
3644
"@types/js-yaml": "^4.0.9",
3745
"@types/node": "^22.0.0",

src/exports.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { query, tool } from "./sdk.js";
55
export type {
66
Query,
77
QueryOptions,
8+
SandboxOptions,
89
GCMessage,
910
GCAssistantMessage,
1011
GCUserMessage,
@@ -27,5 +28,9 @@ export type { SubAgentMetadata } from "./agents.js";
2728
export type { ComplianceWarning } from "./compliance.js";
2829
export type { EnvConfig } from "./config.js";
2930

31+
// Sandbox
32+
export type { SandboxConfig, SandboxContext } from "./sandbox.js";
33+
export { createSandboxContext } from "./sandbox.js";
34+
3035
// Loader (escape hatch)
3136
export { loadAgent } from "./loader.js";

src/index.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import { createInterface } from "readline";
44
import { Agent } from "@mariozechner/pi-agent-core";
55
import type { AgentEvent, AgentTool } from "@mariozechner/pi-agent-core";
66
import { loadAgent } from "./loader.js";
7-
import { createCliTool } from "./tools/cli.js";
8-
import { createReadTool } from "./tools/read.js";
9-
import { createWriteTool } from "./tools/write.js";
10-
import { createMemoryTool } from "./tools/memory.js";
7+
import { createBuiltinTools } from "./tools/index.js";
8+
import { createSandboxContext } from "./sandbox.js";
9+
import type { SandboxContext, SandboxConfig } from "./sandbox.js";
1110
import { expandSkillCommand } from "./skills.js";
1211
import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js";
1312
import type { HooksConfig } from "./hooks.js";
@@ -24,12 +23,13 @@ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
2423
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
2524
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
2625

27-
function parseArgs(argv: string[]): { model?: string; dir: string; prompt?: string; env?: string } {
26+
function parseArgs(argv: string[]): { model?: string; dir: string; prompt?: string; env?: string; sandbox?: boolean } {
2827
const args = argv.slice(2);
2928
let model: string | undefined;
3029
let dir = process.cwd();
3130
let prompt: string | undefined;
3231
let env: string | undefined;
32+
let sandbox = false;
3333

3434
for (let i = 0; i < args.length; i++) {
3535
switch (args[i]) {
@@ -49,6 +49,10 @@ function parseArgs(argv: string[]): { model?: string; dir: string; prompt?: stri
4949
case "-e":
5050
env = args[++i];
5151
break;
52+
case "--sandbox":
53+
case "-s":
54+
sandbox = true;
55+
break;
5256
default:
5357
if (!args[i].startsWith("-")) {
5458
prompt = args[i];
@@ -57,7 +61,7 @@ function parseArgs(argv: string[]): { model?: string; dir: string; prompt?: stri
5761
}
5862
}
5963

60-
return { model, dir, prompt, env };
64+
return { model, dir, prompt, env, sandbox };
6165
}
6266

6367
function handleEvent(
@@ -235,7 +239,7 @@ async function ensureRepo(dir: string, model?: string): Promise<string> {
235239
}
236240

237241
async function main(): Promise<void> {
238-
const { model, dir: rawDir, prompt, env } = parseArgs(process.argv);
242+
const { model, dir: rawDir, prompt, env, sandbox: useSandbox } = parseArgs(process.argv);
239243

240244
// If no --dir given interactively, ask for it
241245
let dir = rawDir;
@@ -246,8 +250,22 @@ async function main(): Promise<void> {
246250
}
247251
}
248252

249-
// Ensure the target is a valid gitclaw repo
250-
dir = await ensureRepo(dir, model);
253+
// Create sandbox context if --sandbox flag is set
254+
let sandboxCtx: SandboxContext | undefined;
255+
if (useSandbox) {
256+
const sandboxConfig: SandboxConfig = { provider: "e2b" };
257+
sandboxCtx = await createSandboxContext(sandboxConfig, resolve(dir));
258+
console.log(dim("Starting sandbox VM..."));
259+
await sandboxCtx.gitMachine.start();
260+
console.log(dim(`Sandbox ready (repo: ${sandboxCtx.repoPath})`));
261+
}
262+
263+
// Ensure the target is a valid gitclaw repo (skip in sandbox mode — gitmachine clones the repo)
264+
if (!useSandbox) {
265+
dir = await ensureRepo(dir, model);
266+
} else {
267+
dir = resolve(dir);
268+
}
251269

252270
let loaded;
253271
try {
@@ -312,12 +330,11 @@ async function main(): Promise<void> {
312330
}
313331

314332
// Build tools — built-in + declarative
315-
let tools: AgentTool<any>[] = [
316-
createCliTool(dir, manifest.runtime.timeout),
317-
createReadTool(dir),
318-
createWriteTool(dir),
319-
createMemoryTool(dir),
320-
];
333+
let tools: AgentTool<any>[] = createBuiltinTools({
334+
dir,
335+
timeout: manifest.runtime.timeout,
336+
sandbox: sandboxCtx,
337+
});
321338

322339
// Load declarative tools from tools/*.yaml (Phase 2.2)
323340
const declarativeTools = await loadDeclarativeTools(agentDir);
@@ -380,6 +397,11 @@ async function main(): Promise<void> {
380397
}).catch(() => {});
381398
}
382399
throw err;
400+
} finally {
401+
if (sandboxCtx) {
402+
console.log(dim("Stopping sandbox..."));
403+
await sandboxCtx.gitMachine.stop();
404+
}
383405
}
384406
return;
385407
}
@@ -401,6 +423,7 @@ async function main(): Promise<void> {
401423

402424
if (trimmed === "/quit" || trimmed === "/exit") {
403425
rl.close();
426+
await stopSandbox();
404427
process.exit(0);
405428
}
406429

@@ -463,14 +486,22 @@ async function main(): Promise<void> {
463486
});
464487
};
465488

489+
// Sandbox cleanup helper
490+
const stopSandbox = async () => {
491+
if (sandboxCtx) {
492+
console.log(dim("Stopping sandbox..."));
493+
await sandboxCtx.gitMachine.stop();
494+
}
495+
};
496+
466497
// Handle Ctrl+C during streaming
467498
rl.on("SIGINT", () => {
468499
if (agent.state.isStreaming) {
469500
agent.abort();
470501
} else {
471502
console.log("\nBye!");
472503
rl.close();
473-
process.exit(0);
504+
stopSandbox().finally(() => process.exit(0));
474505
}
475506
});
476507

src/sandbox.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { execSync } from "child_process";
2+
3+
// ── Types ───────────────────────────────────────────────────────────────
4+
5+
export interface SandboxConfig {
6+
provider: "e2b";
7+
template?: string;
8+
timeout?: number;
9+
repository?: string;
10+
token?: string;
11+
session?: string;
12+
autoCommit?: boolean;
13+
envs?: Record<string, string>;
14+
}
15+
16+
/**
17+
* Wraps the gitmachine GitMachine + Machine instances.
18+
* Types are `any` because gitmachine is an optional peer dependency
19+
* loaded via dynamic import — we don't have compile-time types.
20+
*/
21+
export interface SandboxContext {
22+
/** GitMachine instance (gitmachine) — provides run(), commit(), start(), stop() */
23+
gitMachine: any;
24+
/** Underlying Machine instance — provides readFile(), writeFile() */
25+
machine: any;
26+
/** Absolute path to the repo root inside the sandbox (e.g. /home/user/repo) */
27+
repoPath: string;
28+
}
29+
30+
// ── Factory ─────────────────────────────────────────────────────────────
31+
32+
function detectRepoUrl(dir: string): string | null {
33+
try {
34+
return execSync("git remote get-url origin", { cwd: dir, stdio: "pipe" })
35+
.toString()
36+
.trim();
37+
} catch {
38+
return null;
39+
}
40+
}
41+
42+
/**
43+
* Create a SandboxContext by dynamically importing gitmachine.
44+
* Throws a clear error if gitmachine is not installed.
45+
*/
46+
export async function createSandboxContext(
47+
config: SandboxConfig,
48+
dir: string,
49+
): Promise<SandboxContext> {
50+
let gitmachine: any;
51+
try {
52+
// @ts-ignore — gitmachine is an optional peer dependency
53+
gitmachine = await import("gitmachine");
54+
} catch {
55+
throw new Error(
56+
"Sandbox mode requires the 'gitmachine' package.\n" +
57+
"Install it with: npm install gitmachine",
58+
);
59+
}
60+
61+
const token = config.token
62+
|| process.env.GITHUB_TOKEN
63+
|| process.env.GIT_TOKEN;
64+
65+
const repository = config.repository || detectRepoUrl(dir);
66+
if (!repository) {
67+
throw new Error(
68+
"Sandbox mode requires a repository URL. Provide it via --sandbox config, " +
69+
"or ensure the working directory has a git remote named 'origin'.",
70+
);
71+
}
72+
73+
const gitMachine = new gitmachine.GitMachine({
74+
provider: config.provider,
75+
template: config.template,
76+
timeout: config.timeout,
77+
repository,
78+
token,
79+
session: config.session,
80+
autoCommit: config.autoCommit ?? true,
81+
envs: config.envs,
82+
});
83+
84+
// The repo path inside the sandbox is determined by gitmachine after start().
85+
// Convention: /home/user/<repo-name>
86+
const repoName = repository.split("/").pop()?.replace(/\.git$/, "") || "repo";
87+
const repoPath = `/home/user/${repoName}`;
88+
89+
return {
90+
gitMachine,
91+
machine: gitMachine.machine,
92+
repoPath,
93+
};
94+
}

src/sdk-types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ export interface GCToolDefinition {
100100
handler: (args: any, signal?: AbortSignal) => Promise<string | { text: string; details?: any }>;
101101
}
102102

103+
// ── Sandbox options ─────────────────────────────────────────────────────
104+
105+
export interface SandboxOptions {
106+
provider: "e2b";
107+
template?: string;
108+
timeout?: number;
109+
repository?: string;
110+
token?: string;
111+
session?: string;
112+
autoCommit?: boolean;
113+
envs?: Record<string, string>;
114+
}
115+
103116
// ── Query options ──────────────────────────────────────────────────────
104117

105118
export interface QueryOptions {
@@ -113,6 +126,7 @@ export interface QueryOptions {
113126
replaceBuiltinTools?: boolean;
114127
allowedTools?: string[];
115128
disallowedTools?: string[];
129+
sandbox?: SandboxOptions | boolean;
116130
hooks?: GCHooks;
117131
maxTurns?: number;
118132
abortController?: AbortController;

src/sdk.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import type { AgentEvent, AgentTool } from "@mariozechner/pi-agent-core";
33
import type { AssistantMessage } from "@mariozechner/pi-ai";
44
import { loadAgent } from "./loader.js";
55
import type { AgentManifest } from "./loader.js";
6-
import { createCliTool } from "./tools/cli.js";
7-
import { createReadTool } from "./tools/read.js";
8-
import { createWriteTool } from "./tools/write.js";
9-
import { createMemoryTool } from "./tools/memory.js";
6+
import { createBuiltinTools } from "./tools/index.js";
7+
import { createSandboxContext } from "./sandbox.js";
8+
import type { SandboxContext } from "./sandbox.js";
109
import { loadHooksConfig, runHooks, wrapToolWithHooks } from "./hooks.js";
1110
import { loadDeclarativeTools } from "./tool-loader.js";
1211
import { buildTypeboxSchema } from "./tool-loader.js";
@@ -18,6 +17,7 @@ import type {
1817
GCHookContext,
1918
Query,
2019
QueryOptions,
20+
SandboxOptions,
2121
} from "./sdk-types.js";
2222

2323
// ── Event channel ──────────────────────────────────────────────────────
@@ -118,6 +118,9 @@ export function query(options: QueryOptions): Query {
118118
channel.push(msg);
119119
}
120120

121+
// Sandbox context (hoisted for cleanup in catch)
122+
let sandboxCtx: SandboxContext | undefined;
123+
121124
// Async initialization + run
122125
const runPromise = (async () => {
123126
const dir = options.dir ?? process.cwd();
@@ -136,16 +139,23 @@ export function query(options: QueryOptions): Query {
136139
systemPrompt += "\n\n" + options.systemPromptSuffix;
137140
}
138141

139-
// 3. Build tools
142+
// 3. Build tools (with optional sandbox)
143+
if (options.sandbox) {
144+
const sandboxConfig: SandboxOptions = options.sandbox === true
145+
? { provider: "e2b" }
146+
: options.sandbox;
147+
sandboxCtx = await createSandboxContext(sandboxConfig, dir);
148+
await sandboxCtx.gitMachine.start();
149+
}
150+
140151
let tools: AgentTool<any>[] = [];
141152

142153
if (!options.replaceBuiltinTools) {
143-
tools = [
144-
createCliTool(dir, loaded.manifest.runtime.timeout),
145-
createReadTool(dir),
146-
createWriteTool(dir),
147-
createMemoryTool(dir),
148-
];
154+
tools = createBuiltinTools({
155+
dir,
156+
timeout: loaded.manifest.runtime.timeout,
157+
sandbox: sandboxCtx,
158+
});
149159
}
150160

151161
// Declarative tools from tools/*.yaml
@@ -387,9 +397,19 @@ export function query(options: QueryOptions): Query {
387397
}
388398
}
389399

400+
// Stop sandbox if active
401+
if (sandboxCtx) {
402+
await sandboxCtx.gitMachine.stop().catch(() => {});
403+
}
404+
390405
// Ensure channel finishes even if no agent_end event
391406
channel.finish();
392-
})().catch((err) => {
407+
})().catch(async (err) => {
408+
// Stop sandbox on error
409+
if (sandboxCtx) {
410+
await sandboxCtx.gitMachine.stop().catch(() => {});
411+
}
412+
393413
// Fire on_error hooks
394414
if (options.hooks?.onError) {
395415
Promise.resolve(options.hooks.onError({

0 commit comments

Comments
 (0)