feat: introduce loop-sandbox for isolated worktree execution (#370) · octo-patch/loop-engineering@f02f504 · GitHub
Skip to content

Commit f02f504

Browse files
feat: introduce loop-sandbox for isolated worktree execution (cobusgreyling#370)
Add loop-sandbox: ephemeral git worktree isolation with patch capture for agent runs. Cleanup is scoped to the sandbox run, binary patches are preserved, extract failures retain the worktree, and docs state cwd-only isolation limits (not a container air-gap). Co-authored-by: THRISHAL DOMA <domathrishal123@gmail.com>
1 parent 37d4e09 commit f02f504

13 files changed

Lines changed: 926 additions & 2 deletions

File tree

‎package.json‎

Lines changed: 3 additions & 2 deletions

‎tools/loop-sandbox/README.md‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# loop-sandbox
2+
3+
Ephemeral worktree isolation for Loop Engineering. Divert agent execution into an isolated git worktree, capture their changes as a reviewable `.patch` file, and instantly destroy the sandbox.
4+
5+
## Why loop-sandbox?
6+
7+
Unattended AI agents (loops) running directly in your main working tree can cause damage, overwrite untracked files, or make it incredibly tedious to untangle failed execution attempts.
8+
9+
`loop-sandbox` solves this by providing **ephemeral worktree isolation**:
10+
1. It automatically creates an isolated git worktree for the agent to run in.
11+
2. The agent runs its command, interacting with the codebase completely naturally.
12+
3. When the command finishes, `loop-sandbox` automatically captures the agent's edits (including new untracked files) into a clean `.patch` file.
13+
4. It nukes the worktree, keeping your main repo completely clean.
14+
15+
A human can then safely review the patch and apply it with one keystroke.
16+
17+
> [!WARNING]
18+
> **Isolation Limits:** This is not a containerized air-gap. The agent process retains full filesystem and network access. It can escape the worktree (e.g. `../../`), and changes outside the worktree or to `.gitignored` files will **not** be captured in the patch. This tool is complementary to [docs/safety.md](../../docs/safety.md) and does not replace OS-level sandboxing for hostile code.
19+
20+
## Installation
21+
22+
```bash
23+
npm install -g @cobusgreyling/loop-sandbox
24+
# or run directly via npx
25+
npx @cobusgreyling/loop-sandbox run -- <command>
26+
```
27+
28+
> [!IMPORTANT]
29+
> **Required Gitignores:** You must add the following to your project's `.gitignore` to prevent patches and worktree manifests from being committed:
30+
> ```
31+
> .loop-sandbox/
32+
> .loop-worktrees/
33+
> ```
34+
35+
## Usage
36+
37+
```bash
38+
loop-sandbox <command> [options]
39+
```
40+
41+
### Commands
42+
43+
| Command | Description |
44+
|---------|-------------|
45+
| `run [opts] -- <cmd>` | Run an agent command inside the isolated sandbox |
46+
| `review` / `list` | List isolated patches ready for human review |
47+
48+
### Options for `run`
49+
50+
| Option | Description |
51+
|--------|-------------|
52+
| `--shell` | Spawns the command using `shell: true` (for bash -c, etc.) |
53+
| `--base <branch>` | The base branch for the worktree (defaults to current HEAD) |
54+
55+
### Examples
56+
57+
**Running a tool via an agent:**
58+
```bash
59+
# Safely let an agent run your linter/formatter without polluting your working tree
60+
npx @cobusgreyling/loop-sandbox run -- npx my-agent
61+
```
62+
63+
**Running a shell command:**
64+
```bash
65+
npx @cobusgreyling/loop-sandbox run --shell -- bash -c "echo 'hello' > test.txt"
66+
```
67+
68+
**Reviewing and applying patches:**
69+
```bash
70+
# List all patches the sandbox caught
71+
npx @cobusgreyling/loop-sandbox review
72+
73+
# Example output:
74+
# === Loop Sandbox Patches ===
75+
# 📄 sandbox-82f1e394.patch (1.2 KB)
76+
# Apply: git apply .loop-sandbox/patches/sandbox-82f1e394.patch
77+
```
78+
79+
## How it works
80+
81+
Under the hood, `loop-sandbox` leverages `@cobusgreyling/loop-worktree` and native `git worktree` primitives. It creates a temporary branch from your current HEAD, spawns your process with its `cwd` set to the isolated tree, runs `git diff --cached` to generate the patch, and uses `git worktree remove --force` to clean up.

‎tools/loop-sandbox/dist/cli.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
export {};

‎tools/loop-sandbox/dist/cli.js‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env node
2+
import path from 'node:path';
3+
import { runInSandbox, listPatches } from './sandbox.js';
4+
async function main() {
5+
const args = process.argv.slice(2);
6+
if (args.length === 0 || args[0] === 'help' || args[0] === '--help') {
7+
printHelp();
8+
process.exit(0);
9+
}
10+
const command = args[0];
11+
const root = process.cwd();
12+
if (command === 'run') {
13+
const runArgs = args.slice(1);
14+
let targetCommandIndex = runArgs.findIndex(a => a === '--');
15+
if (targetCommandIndex === -1) {
16+
console.error('❌ Missing `--` separator before the command. Usage: loop-sandbox run [options] -- <command>');
17+
process.exit(1);
18+
}
19+
const sandboxOptionsArgs = runArgs.slice(0, targetCommandIndex);
20+
const targetCommandArgs = runArgs.slice(targetCommandIndex + 1);
21+
if (targetCommandArgs.length === 0) {
22+
console.error('❌ Missing command to run. Usage: loop-sandbox run [options] -- <command>');
23+
process.exit(1);
24+
}
25+
let shell = false;
26+
let base;
27+
for (let i = 0; i < sandboxOptionsArgs.length; i++) {
28+
const arg = sandboxOptionsArgs[i];
29+
if (arg === '--shell') {
30+
shell = true;
31+
}
32+
else if (arg === '--base') {
33+
base = sandboxOptionsArgs[++i];
34+
}
35+
else {
36+
console.error(`❌ Unknown option: ${arg}`);
37+
process.exit(1);
38+
}
39+
}
40+
const exe = targetCommandArgs[0];
41+
const exeArgs = targetCommandArgs.slice(1);
42+
try {
43+
const result = await runInSandbox(root, exe, exeArgs, { shell, base });
44+
if (result.hasChanges && result.patchFile) {
45+
console.log('\n==================================================');
46+
console.log(`🎉 Run completed. Changes isolated successfully!`);
47+
console.log(`To review the changes, run:`);
48+
console.log(` npx @cobusgreyling/loop-sandbox review`);
49+
console.log(`\nTo apply the changes immediately, run:`);
50+
console.log(` git apply ${path.relative(root, result.patchFile)}`);
51+
console.log('==================================================\n');
52+
}
53+
else {
54+
console.log(`\nℹ️ Run completed with exit code ${result.exitCode ?? 0}. No changes detected.`);
55+
}
56+
process.exit(result.exitCode ?? 0);
57+
}
58+
catch (err) {
59+
console.error(`❌ sandbox error: ${err.message}`);
60+
process.exit(1);
61+
}
62+
}
63+
else if (command === 'review' || command === 'list') {
64+
try {
65+
const patches = await listPatches(root);
66+
if (patches.length === 0) {
67+
console.log('No sandbox patches found in .loop-sandbox/patches/');
68+
return;
69+
}
70+
console.log('\n=== Loop Sandbox Patches ===\n');
71+
for (const p of patches) {
72+
const kb = (p.size / 1024).toFixed(1);
73+
console.log(`📄 ${p.patchName} (${kb} KB)`);
74+
console.log(` Apply: git apply ${path.relative(root, p.patchPath)}`);
75+
}
76+
console.log('');
77+
}
78+
catch (err) {
79+
console.error(`❌ sandbox error: ${err.message}`);
80+
process.exit(1);
81+
}
82+
}
83+
else {
84+
console.error(`❌ Unknown command: ${command}`);
85+
printHelp();
86+
process.exit(1);
87+
}
88+
}
89+
function printHelp() {
90+
console.log(`
91+
loop-sandbox — Ephemeral worktree isolation for agents.
92+
93+
Usage:
94+
loop-sandbox run [options] -- <command> Run an agent command inside an isolated worktree.
95+
loop-sandbox review List isolated patches ready for human review.
96+
97+
Options for 'run':
98+
--shell Run the command inside a shell environment (shell: true)
99+
--base The base branch to branch the worktree from (default: current HEAD)
100+
101+
Examples:
102+
npx @cobusgreyling/loop-sandbox run -- node script.js
103+
npx @cobusgreyling/loop-sandbox run --shell -- bash -c "echo 'hello' > test.txt"
104+
npx @cobusgreyling/loop-sandbox review
105+
`);
106+
}
107+
main().catch((err) => {
108+
console.error(err);
109+
process.exit(1);
110+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface SandboxOptions {
2+
shell?: boolean;
3+
base?: string;
4+
}
5+
export interface SandboxResult {
6+
runId: string;
7+
patchFile: string | null;
8+
exitCode: number | null;
9+
hasChanges: boolean;
10+
}
11+
/**
12+
* Wraps an agent command in a temporary git worktree sandbox.
13+
* Returns the patch file path if changes were made.
14+
*/
15+
export declare function runInSandbox(root: string, command: string, args: string[], options?: SandboxOptions): Promise<SandboxResult>;
16+
export interface ReviewItem {
17+
patchName: string;
18+
patchPath: string;
19+
size: number;
20+
}
21+
/** Lists all available patches in the .loop-sandbox/patches/ directory. */
22+
export declare function listPatches(root: string): Promise<ReviewItem[]>;

‎tools/loop-sandbox/dist/sandbox.js‎

Lines changed: 150 additions & 0 deletions

0 commit comments

Comments
 (0)