sandwrap 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +385 -0
  2. package/dist/index.js +697 -0
  3. package/package.json +40 -0
package/README.md ADDED
@@ -0,0 +1,385 @@
1
+ # sandwrap
2
+
3
+ A simple CLI tool that runs any command inside a bubblewrap + overlayfs sandbox, preventing it from modifying your actual filesystem. After the command exits, you can review diffs and selectively apply or discard changes.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ bunx sandwrap <command> [args...]
9
+
10
+ # Examples
11
+ bunx sandwrap claude
12
+ bunx sandwrap claude code
13
+ bunx sandwrap ./my-agent.sh
14
+ bunx sandwrap npm run dangerous-script
15
+ ```
16
+
17
+ ## How It Works
18
+
19
+ ```
20
+ ┌─────────────────────────────────────────────────────────────┐
21
+ │ sandwrap process │
22
+ ├─────────────────────────────────────────────────────────────┤
23
+ │ │
24
+ │ 1. Setup Phase │
25
+ │ ┌─────────────────────────────────────────────────┐ │
26
+ │ │ Create temp directory structure: │ │
27
+ │ │ /tmp/sandwrap-XXXX/ │ │
28
+ │ │ ├── upper/ (overlay writes go here) │ │
29
+ │ │ ├── work/ (overlayfs workdir) │ │
30
+ │ │ └── merged/ (union mount point) │ │
31
+ │ └─────────────────────────────────────────────────┘ │
32
+ │ │
33
+ │ 2. Execution Phase │
34
+ │ ┌─────────────────────────────────────────────────┐ │
35
+ │ │ bwrap invocation: │ │
36
+ │ │ - Mount CWD as lower (read-only) │ │
37
+ │ │ - Mount upper + lower as overlay │ │
38
+ │ │ - Bind essential dirs (/usr, /bin, etc.) │ │
39
+ │ │ - Run <command> inside sandbox │ │
40
+ │ │ - All writes captured in upper/ │ │
41
+ │ └─────────────────────────────────────────────────┘ │
42
+ │ │
43
+ │ 3. Review Phase (after command exits) │
44
+ │ ┌─────────────────────────────────────────────────┐ │
45
+ │ │ - Scan upper/ for changes │ │
46
+ │ │ - Generate diffs for modified files │ │
47
+ │ │ - Show new/deleted files │ │
48
+ │ │ - Interactive prompt: apply/discard/review │ │
49
+ │ └─────────────────────────────────────────────────┘ │
50
+ │ │
51
+ └─────────────────────────────────────────────────────────────┘
52
+ ```
53
+
54
+ ## CLI Interface
55
+
56
+ ### Main Command
57
+
58
+ ```
59
+ sandwrap <command> [args...]
60
+
61
+ Options:
62
+ --no-network, -n Disable network access inside sandbox
63
+ --keep, -k Keep overlay directory after exit (for debugging)
64
+ --auto-apply, -y Apply all changes without prompting
65
+ --auto-discard, -d Discard all changes without prompting
66
+ --help, -h Show help
67
+ --version, -v Show version
68
+ ```
69
+
70
+ ### Post-Execution Review
71
+
72
+ After the sandboxed command exits, sandwrap enters interactive review mode:
73
+
74
+ ```
75
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
76
+ sandwrap: command exited with code 0
77
+
78
+ Changes detected:
79
+ M src/index.ts (+42, -13)
80
+ M package.json (+2, -1)
81
+ A src/utils/new.ts (+87)
82
+ D old-config.json
83
+
84
+ Total: 2 modified, 1 added, 1 deleted
85
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
86
+
87
+ What would you like to do?
88
+
89
+ [a] Apply all changes
90
+ [d] Discard all changes
91
+ [r] Review changes interactively
92
+ [s] Select files to apply
93
+ [q] Quit (discard all)
94
+
95
+ >
96
+ ```
97
+
98
+ ### Interactive Review Mode (`r`)
99
+
100
+ ```
101
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
102
+ Reviewing: src/index.ts (1/4)
103
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
104
+
105
+ --- a/src/index.ts
106
+ +++ b/src/index.ts
107
+ @@ -10,6 +10,12 @@ import { foo } from './foo';
108
+
109
+ export function main() {
110
+ + // Added safety check
111
+ + if (!validateInput(input)) {
112
+ + throw new Error('Invalid input');
113
+ + }
114
+ +
115
+ const result = process(input);
116
+ return result;
117
+ }
118
+
119
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
+ [y] Apply this file [n] Skip [v] View full file
121
+ [e] Edit before applying [q] Quit review
122
+ >
123
+ ```
124
+
125
+ ### Select Files Mode (`s`)
126
+
127
+ ```
128
+ Select files to apply (space to toggle, enter to confirm):
129
+
130
+ [x] M src/index.ts
131
+ [ ] M package.json
132
+ [x] A src/utils/new.ts
133
+ [ ] D old-config.json
134
+
135
+ >
136
+ ```
137
+
138
+ ## Architecture
139
+
140
+ ```
141
+ sandwrap/
142
+ ├── package.json
143
+ ├── src/
144
+ │ ├── index.ts # Entry point & CLI parsing
145
+ │ ├── sandbox.ts # bwrap + overlay setup/teardown
146
+ │ ├── diff.ts # Change detection & diff generation
147
+ │ ├── review.ts # Interactive review UI
148
+ │ └── apply.ts # File operations to apply changes
149
+ └── README.md
150
+ ```
151
+
152
+ ## Core Module: `sandbox.ts`
153
+
154
+ Responsible for:
155
+
156
+ 1. **Creating overlay structure**
157
+ ```typescript
158
+ interface SandboxContext {
159
+ id: string;
160
+ tempDir: string;
161
+ upperDir: string; // Where writes go
162
+ workDir: string; // OverlayFS requirement
163
+ mergedDir: string; // Union mount point
164
+ targetDir: string; // Original CWD being sandboxed
165
+ }
166
+ ```
167
+
168
+ 2. **Building bwrap command**
169
+ ```bash
170
+ bwrap \
171
+ --ro-bind /usr /usr \
172
+ --ro-bind /lib /lib \
173
+ --ro-bind /lib64 /lib64 \
174
+ --ro-bind /bin /bin \
175
+ --ro-bind /etc /etc \
176
+ --dev /dev \
177
+ --proc /proc \
178
+ --tmpfs /tmp \
179
+ --bind $UPPER $CWD \ # Overlay writes go to upper
180
+ --ro-bind $CWD $CWD/.lower # Read-only access to original
181
+ --chdir $CWD \
182
+ --unshare-user \
183
+ --unshare-pid \
184
+ --unshare-net \ # If --no-network
185
+ --die-with-parent \
186
+ -- <command> [args...]
187
+ ```
188
+
189
+ 3. **Alternative: fuse-overlayfs for unprivileged users**
190
+
191
+ If user lacks permissions for kernel overlayfs, fall back to fuse-overlayfs:
192
+ ```bash
193
+ fuse-overlayfs -o lowerdir=$CWD,upperdir=$UPPER,workdir=$WORK $MERGED
194
+ ```
195
+
196
+ ## Core Module: `diff.ts`
197
+
198
+ Responsible for detecting and formatting changes:
199
+
200
+ ```typescript
201
+ interface FileChange {
202
+ path: string;
203
+ type: 'added' | 'modified' | 'deleted';
204
+ diff?: string; // Unified diff for text files
205
+ linesAdded?: number;
206
+ linesRemoved?: number;
207
+ isBinary: boolean;
208
+ }
209
+
210
+ // Scan upper directory for changes
211
+ function detectChanges(ctx: SandboxContext): FileChange[];
212
+
213
+ // Generate unified diff between original and modified
214
+ function generateDiff(original: string, modified: string): string;
215
+ ```
216
+
217
+ ### Change Detection Logic
218
+
219
+ OverlayFS marks changes in the upper directory:
220
+ - **New files**: Present in upper, not in lower
221
+ - **Modified files**: Present in both (upper shadows lower)
222
+ - **Deleted files**: Character device with 0/0 major/minor (whiteout)
223
+ ```typescript
224
+ // Detect whiteout files (overlayfs deletion markers)
225
+ const stats = await fs.lstat(path);
226
+ const isWhiteout = stats.isCharacterDevice() &&
227
+ stats.rdev === 0;
228
+ ```
229
+
230
+ ## Core Module: `review.ts`
231
+
232
+ Interactive terminal UI using something like `@clack/prompts` or raw readline:
233
+
234
+ ```typescript
235
+ interface ReviewResult {
236
+ filesToApply: string[];
237
+ filesToDiscard: string[];
238
+ }
239
+
240
+ async function reviewChanges(changes: FileChange[]): Promise<ReviewResult>;
241
+ ```
242
+
243
+ ## Core Module: `apply.ts`
244
+
245
+ Applies selected changes from upper directory to original:
246
+
247
+ ```typescript
248
+ async function applyChanges(
249
+ ctx: SandboxContext,
250
+ files: string[]
251
+ ): Promise<void> {
252
+ for (const file of files) {
253
+ const src = path.join(ctx.upperDir, file);
254
+ const dst = path.join(ctx.targetDir, file);
255
+
256
+ const stats = await fs.lstat(src);
257
+
258
+ if (isWhiteout(stats)) {
259
+ // Delete from original
260
+ await fs.rm(dst, { recursive: true });
261
+ } else {
262
+ // Copy from upper to original
263
+ await fs.cp(src, dst, { recursive: true });
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ ## Dependencies
270
+
271
+ ```json
272
+ {
273
+ "name": "sandwrap",
274
+ "version": "0.1.0",
275
+ "bin": {
276
+ "sandwrap": "./dist/index.js"
277
+ },
278
+ "dependencies": {
279
+ "@clack/prompts": "^0.7.0",
280
+ "diff": "^5.2.0",
281
+ "picocolors": "^1.0.0"
282
+ },
283
+ "devDependencies": {
284
+ "typescript": "^5.0.0",
285
+ "@types/node": "^20.0.0",
286
+ "@types/diff": "^5.0.0"
287
+ }
288
+ }
289
+ ```
290
+
291
+ ## Requirements
292
+
293
+ **System dependencies (must be installed):**
294
+ - `bwrap` (bubblewrap) - Usually available as `bubblewrap` package
295
+ - `fuse-overlayfs` (optional fallback for unprivileged overlay)
296
+
297
+ **Check on startup:**
298
+ ```typescript
299
+ async function checkDependencies(): Promise<void> {
300
+ try {
301
+ await execAsync('which bwrap');
302
+ } catch {
303
+ console.error('Error: bubblewrap not found.');
304
+ console.error('Install with: sudo apt install bubblewrap');
305
+ process.exit(1);
306
+ }
307
+ }
308
+ ```
309
+
310
+ ## Edge Cases & Considerations
311
+
312
+ 1. **Binary files**: Show as changed but don't display diff content
313
+ 2. **Symlinks**: Preserve symlink targets when applying
314
+ 3. **Permissions**: Preserve file modes when copying
315
+ 4. **Large files**: Stream diff rather than loading into memory
316
+ 5. **Nested sandboxing**: Detect if already in sandbox and warn/fail
317
+ 6. **Signal handling**: Clean up overlay on SIGINT/SIGTERM
318
+ 7. **Unprivileged users**: Fall back to fuse-overlayfs if needed
319
+ 8. **macOS**: bubblewrap is Linux-only; would need alternative (maybe lima/colima + bwrap inside)
320
+
321
+ ## Example Session
322
+
323
+ ```bash
324
+ $ bunx sandwrap claude
325
+
326
+ # ... claude runs, makes changes ...
327
+ # User types /exit or Ctrl+D
328
+
329
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
330
+ sandwrap: claude exited with code 0
331
+
332
+ Changes detected:
333
+ M src/api.ts (+15, -3)
334
+ M src/types.ts (+8, -0)
335
+ A src/helpers/cache.ts (+45)
336
+
337
+ Total: 2 modified, 1 added, 0 deleted
338
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
339
+
340
+ What would you like to do?
341
+
342
+ [a] Apply all changes
343
+ [d] Discard all changes
344
+ [r] Review changes interactively
345
+ [s] Select files to apply
346
+ [q] Quit (discard all)
347
+
348
+ > r
349
+
350
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
351
+ Reviewing: src/api.ts (1/3)
352
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
353
+
354
+ --- a/src/api.ts
355
+ +++ b/src/api.ts
356
+ @@ -22,6 +22,18 @@ export async function fetchData(id: string) {
357
+ + // Add caching layer
358
+ + const cached = await cache.get(id);
359
+ + if (cached) return cached;
360
+ +
361
+ const response = await fetch(`/api/data/${id}`);
362
+ - return response.json();
363
+ + const data = await response.json();
364
+ + await cache.set(id, data);
365
+ + return data;
366
+ }
367
+
368
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
369
+ [y] Apply [n] Skip [v] View full [q] Quit
370
+ > y
371
+
372
+ ✓ Applied src/api.ts
373
+ ✓ Applied src/types.ts
374
+ ✓ Applied src/helpers/cache.ts
375
+
376
+ Done! 3 files applied, 0 discarded.
377
+ ```
378
+
379
+ ## Future Enhancements
380
+
381
+ - `--snapshot` mode: Save overlay as tarball for later replay
382
+ - `--compare` mode: Run same command with/without changes, compare output
383
+ - Git integration: Stage applied changes automatically
384
+ - Undo support: Keep backup of original files before applying
385
+ - Config file: `.sandwraprc` for default options per project
package/dist/index.js ADDED
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env node
2
+ // index.ts
3
+ import { parseArgs } from "util";
4
+
5
+ // src/sandbox.ts
6
+ import { spawn, execSync } from "child_process";
7
+ import { randomBytes } from "crypto";
8
+ import { join } from "path";
9
+ import { rm, mkdir, cp } from "fs/promises";
10
+ async function checkDependencies() {
11
+ try {
12
+ execSync("which bwrap", { stdio: "ignore" });
13
+ } catch {
14
+ console.error("Error: bubblewrap not found.");
15
+ console.error("Install with: sudo apt install bubblewrap");
16
+ process.exit(1);
17
+ }
18
+ }
19
+ async function createSandbox(targetDir) {
20
+ const id = randomBytes(4).toString("hex");
21
+ const tempDir = join("/tmp", `sandwrap-${id}`);
22
+ const upperDir = join(tempDir, "upper");
23
+ const workDir = join(tempDir, "work");
24
+ const mergedDir = join(tempDir, "merged");
25
+ await mkdir(upperDir, { recursive: true });
26
+ await mkdir(workDir, { recursive: true });
27
+ await mkdir(mergedDir, { recursive: true });
28
+ return {
29
+ id,
30
+ tempDir,
31
+ upperDir,
32
+ workDir,
33
+ mergedDir,
34
+ targetDir
35
+ };
36
+ }
37
+ function dirExists(path) {
38
+ try {
39
+ execSync(`test -d ${path}`, { stdio: "ignore" });
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function runInSandbox(ctx, options) {
46
+ const args = [
47
+ "--ro-bind",
48
+ "/usr",
49
+ "/usr",
50
+ "--ro-bind",
51
+ "/bin",
52
+ "/bin",
53
+ "--ro-bind",
54
+ "/etc",
55
+ "/etc",
56
+ "--dev",
57
+ "/dev",
58
+ "--proc",
59
+ "/proc",
60
+ "--tmpfs",
61
+ "/tmp"
62
+ ];
63
+ if (dirExists("/lib")) {
64
+ args.push("--ro-bind", "/lib", "/lib");
65
+ }
66
+ if (dirExists("/lib64")) {
67
+ args.push("--ro-bind", "/lib64", "/lib64");
68
+ }
69
+ if (dirExists("/sbin")) {
70
+ args.push("--ro-bind", "/sbin", "/sbin");
71
+ }
72
+ const homeDir = process.env.HOME;
73
+ if (homeDir) {
74
+ args.push("--ro-bind", homeDir, homeDir);
75
+ }
76
+ args.push("--bind", ctx.upperDir, ctx.targetDir);
77
+ await cp(ctx.targetDir, ctx.upperDir, { recursive: true });
78
+ args.push("--chdir", ctx.targetDir);
79
+ args.push("--unshare-pid");
80
+ args.push("--die-with-parent");
81
+ if (options.noNetwork) {
82
+ args.push("--unshare-net");
83
+ }
84
+ args.push("--");
85
+ args.push(...options.command);
86
+ return new Promise((resolve) => {
87
+ const proc = spawn("bwrap", args, {
88
+ stdio: "inherit",
89
+ env: {
90
+ ...process.env,
91
+ SANDWRAP: "1",
92
+ SANDWRAP_ID: ctx.id
93
+ }
94
+ });
95
+ proc.on("close", (code) => {
96
+ resolve(code ?? 1);
97
+ });
98
+ proc.on("error", (err) => {
99
+ console.error(`Failed to start sandbox: ${err.message}`);
100
+ resolve(1);
101
+ });
102
+ });
103
+ }
104
+ async function cleanupSandbox(ctx) {
105
+ await rm(ctx.tempDir, { recursive: true, force: true });
106
+ }
107
+
108
+ // src/diff.ts
109
+ import { join as join2, relative } from "path";
110
+ import { readdir, lstat, readFile } from "fs/promises";
111
+ async function isBinaryFile(filePath) {
112
+ try {
113
+ const buffer = await readFile(filePath);
114
+ const slice = buffer.subarray(0, 8192);
115
+ for (const byte of slice) {
116
+ if (byte === 0)
117
+ return true;
118
+ }
119
+ return false;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ function generateUnifiedDiff(originalContent, modifiedContent, filePath) {
125
+ const originalLines = originalContent.split(`
126
+ `);
127
+ const modifiedLines = modifiedContent.split(`
128
+ `);
129
+ const result = [];
130
+ result.push(`--- a/${filePath}`);
131
+ result.push(`+++ b/${filePath}`);
132
+ let i = 0, j = 0;
133
+ let hunkStart = -1;
134
+ let hunkLines = [];
135
+ const flushHunk = () => {
136
+ if (hunkLines.length > 0) {
137
+ result.push(`@@ -${hunkStart + 1} +${hunkStart + 1} @@`);
138
+ result.push(...hunkLines);
139
+ hunkLines = [];
140
+ }
141
+ };
142
+ while (i < originalLines.length || j < modifiedLines.length) {
143
+ if (i < originalLines.length && j < modifiedLines.length) {
144
+ if (originalLines[i] === modifiedLines[j]) {
145
+ flushHunk();
146
+ i++;
147
+ j++;
148
+ } else {
149
+ if (hunkStart === -1)
150
+ hunkStart = i;
151
+ const origInMod = modifiedLines.indexOf(originalLines[i], j);
152
+ const modInOrig = originalLines.indexOf(modifiedLines[j], i);
153
+ if (origInMod === -1 && modInOrig === -1) {
154
+ hunkLines.push(`-${originalLines[i]}`);
155
+ hunkLines.push(`+${modifiedLines[j]}`);
156
+ i++;
157
+ j++;
158
+ } else if (origInMod === -1) {
159
+ hunkLines.push(`-${originalLines[i]}`);
160
+ i++;
161
+ } else {
162
+ hunkLines.push(`+${modifiedLines[j]}`);
163
+ j++;
164
+ }
165
+ }
166
+ } else if (i < originalLines.length) {
167
+ if (hunkStart === -1)
168
+ hunkStart = i;
169
+ hunkLines.push(`-${originalLines[i]}`);
170
+ i++;
171
+ } else {
172
+ if (hunkStart === -1)
173
+ hunkStart = j;
174
+ hunkLines.push(`+${modifiedLines[j]}`);
175
+ j++;
176
+ }
177
+ }
178
+ flushHunk();
179
+ return result.join(`
180
+ `);
181
+ }
182
+ async function* walkDir(dir) {
183
+ const entries = await readdir(dir, { withFileTypes: true });
184
+ for (const entry of entries) {
185
+ const fullPath = join2(dir, entry.name);
186
+ if (entry.isDirectory()) {
187
+ yield* walkDir(fullPath);
188
+ } else {
189
+ yield fullPath;
190
+ }
191
+ }
192
+ }
193
+ async function detectChanges(ctx) {
194
+ const changes = [];
195
+ const originalFiles = new Set;
196
+ try {
197
+ for await (const file of walkDir(ctx.targetDir)) {
198
+ const relPath = relative(ctx.targetDir, file);
199
+ originalFiles.add(relPath);
200
+ }
201
+ } catch {}
202
+ const upperFiles = new Set;
203
+ try {
204
+ for await (const file of walkDir(ctx.upperDir)) {
205
+ const relPath = relative(ctx.upperDir, file);
206
+ upperFiles.add(relPath);
207
+ const upperPath = file;
208
+ const originalPath = join2(ctx.targetDir, relPath);
209
+ const upperStats = await lstat(upperPath);
210
+ if (upperStats.isCharacterDevice() && upperStats.rdev === 0) {
211
+ changes.push({
212
+ path: relPath,
213
+ type: "deleted",
214
+ linesAdded: 0,
215
+ linesRemoved: 0,
216
+ isBinary: false
217
+ });
218
+ continue;
219
+ }
220
+ const binary = await isBinaryFile(upperPath);
221
+ const existsInOriginal = originalFiles.has(relPath);
222
+ if (!existsInOriginal) {
223
+ let linesAdded = 0;
224
+ if (!binary) {
225
+ const content = await readFile(upperPath, "utf-8");
226
+ linesAdded = content.split(`
227
+ `).length;
228
+ }
229
+ changes.push({
230
+ path: relPath,
231
+ type: "added",
232
+ linesAdded,
233
+ linesRemoved: 0,
234
+ isBinary: binary
235
+ });
236
+ } else {
237
+ const upperContent = await readFile(upperPath);
238
+ let originalContent;
239
+ try {
240
+ originalContent = await readFile(originalPath);
241
+ } catch {
242
+ changes.push({
243
+ path: relPath,
244
+ type: "added",
245
+ linesAdded: 0,
246
+ linesRemoved: 0,
247
+ isBinary: binary
248
+ });
249
+ continue;
250
+ }
251
+ const same = upperContent.equals(originalContent);
252
+ if (!same) {
253
+ let diff;
254
+ let linesAdded = 0;
255
+ let linesRemoved = 0;
256
+ if (!binary) {
257
+ const origText = originalContent.toString("utf-8");
258
+ const modText = upperContent.toString("utf-8");
259
+ diff = generateUnifiedDiff(origText, modText, relPath);
260
+ for (const line of diff.split(`
261
+ `)) {
262
+ if (line.startsWith("+") && !line.startsWith("+++"))
263
+ linesAdded++;
264
+ if (line.startsWith("-") && !line.startsWith("---"))
265
+ linesRemoved++;
266
+ }
267
+ }
268
+ changes.push({
269
+ path: relPath,
270
+ type: "modified",
271
+ diff,
272
+ linesAdded,
273
+ linesRemoved,
274
+ isBinary: binary
275
+ });
276
+ }
277
+ }
278
+ }
279
+ } catch (err) {}
280
+ for (const originalFile of originalFiles) {
281
+ if (!upperFiles.has(originalFile)) {
282
+ const originalPath = join2(ctx.targetDir, originalFile);
283
+ const binary = await isBinaryFile(originalPath);
284
+ let linesRemoved = 0;
285
+ if (!binary) {
286
+ try {
287
+ const content = await readFile(originalPath, "utf-8");
288
+ linesRemoved = content.split(`
289
+ `).length;
290
+ } catch {}
291
+ }
292
+ changes.push({
293
+ path: originalFile,
294
+ type: "deleted",
295
+ linesAdded: 0,
296
+ linesRemoved,
297
+ isBinary: binary
298
+ });
299
+ }
300
+ }
301
+ changes.sort((a, b) => a.path.localeCompare(b.path));
302
+ return changes;
303
+ }
304
+
305
+ // src/review.ts
306
+ import * as readline from "readline";
307
+ var colors = {
308
+ reset: "\x1B[0m",
309
+ bold: "\x1B[1m",
310
+ dim: "\x1B[2m",
311
+ red: "\x1B[31m",
312
+ green: "\x1B[32m",
313
+ yellow: "\x1B[33m",
314
+ blue: "\x1B[34m",
315
+ cyan: "\x1B[36m"
316
+ };
317
+ function colorize(text, color) {
318
+ return `${colors[color]}${text}${colors.reset}`;
319
+ }
320
+ function formatChangeType(type) {
321
+ switch (type) {
322
+ case "added":
323
+ return colorize("A", "green");
324
+ case "modified":
325
+ return colorize("M", "yellow");
326
+ case "deleted":
327
+ return colorize("D", "red");
328
+ }
329
+ }
330
+ function formatStats(change) {
331
+ if (change.isBinary)
332
+ return colorize("[binary]", "dim");
333
+ const parts = [];
334
+ if (change.linesAdded > 0)
335
+ parts.push(colorize(`+${change.linesAdded}`, "green"));
336
+ if (change.linesRemoved > 0)
337
+ parts.push(colorize(`-${change.linesRemoved}`, "red"));
338
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
339
+ }
340
+ function printSeparator() {
341
+ console.log(colorize("━".repeat(60), "dim"));
342
+ }
343
+ function printChangeSummary(changes, exitCode) {
344
+ printSeparator();
345
+ console.log(` ${colorize("sandwrap", "bold")}: command exited with code ${exitCode}`);
346
+ console.log();
347
+ if (changes.length === 0) {
348
+ console.log(" No changes detected.");
349
+ printSeparator();
350
+ return;
351
+ }
352
+ console.log(" Changes detected:");
353
+ for (const change of changes) {
354
+ const typeStr = formatChangeType(change.type);
355
+ const stats = formatStats(change);
356
+ console.log(` ${typeStr} ${change.path.padEnd(30)} ${stats}`);
357
+ }
358
+ console.log();
359
+ const added = changes.filter((c) => c.type === "added").length;
360
+ const modified = changes.filter((c) => c.type === "modified").length;
361
+ const deleted = changes.filter((c) => c.type === "deleted").length;
362
+ console.log(` Total: ${modified} modified, ${added} added, ${deleted} deleted`);
363
+ printSeparator();
364
+ }
365
+ function printDiff(change) {
366
+ if (!change.diff) {
367
+ if (change.isBinary) {
368
+ console.log(colorize(" [Binary file]", "dim"));
369
+ }
370
+ return;
371
+ }
372
+ for (const line of change.diff.split(`
373
+ `)) {
374
+ if (line.startsWith("+++") || line.startsWith("---")) {
375
+ console.log(colorize(line, "bold"));
376
+ } else if (line.startsWith("+")) {
377
+ console.log(colorize(line, "green"));
378
+ } else if (line.startsWith("-")) {
379
+ console.log(colorize(line, "red"));
380
+ } else if (line.startsWith("@@")) {
381
+ console.log(colorize(line, "cyan"));
382
+ } else {
383
+ console.log(line);
384
+ }
385
+ }
386
+ }
387
+ async function prompt(question) {
388
+ const rl = readline.createInterface({
389
+ input: process.stdin,
390
+ output: process.stdout
391
+ });
392
+ return new Promise((resolve) => {
393
+ rl.question(question, (answer) => {
394
+ rl.close();
395
+ resolve(answer.trim().toLowerCase());
396
+ });
397
+ });
398
+ }
399
+ async function selectFiles(changes) {
400
+ const selected = new Set(changes.map((c) => c.path));
401
+ console.log(`
402
+ Select files to apply (enter number to toggle, 'done' to confirm):
403
+ `);
404
+ const printList = () => {
405
+ for (let i = 0;i < changes.length; i++) {
406
+ const change = changes[i];
407
+ const check = selected.has(change.path) ? "[x]" : "[ ]";
408
+ const typeStr = formatChangeType(change.type);
409
+ console.log(` ${i + 1}. ${check} ${typeStr} ${change.path}`);
410
+ }
411
+ };
412
+ printList();
413
+ while (true) {
414
+ const answer = await prompt(`
415
+ > `);
416
+ if (answer === "done" || answer === "d" || answer === "") {
417
+ break;
418
+ }
419
+ if (answer === "all" || answer === "a") {
420
+ for (const c of changes)
421
+ selected.add(c.path);
422
+ printList();
423
+ continue;
424
+ }
425
+ if (answer === "none" || answer === "n") {
426
+ selected.clear();
427
+ printList();
428
+ continue;
429
+ }
430
+ const num = parseInt(answer, 10);
431
+ if (num >= 1 && num <= changes.length) {
432
+ const path = changes[num - 1].path;
433
+ if (selected.has(path)) {
434
+ selected.delete(path);
435
+ } else {
436
+ selected.add(path);
437
+ }
438
+ printList();
439
+ }
440
+ }
441
+ return Array.from(selected);
442
+ }
443
+ async function reviewInteractively(changes) {
444
+ const filesToApply = [];
445
+ for (let i = 0;i < changes.length; i++) {
446
+ const change = changes[i];
447
+ console.log();
448
+ printSeparator();
449
+ console.log(` Reviewing: ${change.path} (${i + 1}/${changes.length})`);
450
+ printSeparator();
451
+ console.log();
452
+ printDiff(change);
453
+ console.log();
454
+ printSeparator();
455
+ console.log(" [y] Apply [n] Skip [q] Quit review");
456
+ const answer = await prompt("> ");
457
+ if (answer === "y" || answer === "yes") {
458
+ filesToApply.push(change.path);
459
+ console.log(colorize(` ✓ Will apply ${change.path}`, "green"));
460
+ } else if (answer === "q" || answer === "quit") {
461
+ break;
462
+ } else {
463
+ console.log(colorize(` ✗ Skipped ${change.path}`, "dim"));
464
+ }
465
+ }
466
+ return filesToApply;
467
+ }
468
+ async function reviewChanges(changes, exitCode, autoApply, autoDiscard) {
469
+ printChangeSummary(changes, exitCode);
470
+ if (changes.length === 0) {
471
+ return { filesToApply: [], filesToDiscard: [] };
472
+ }
473
+ if (autoApply) {
474
+ console.log(colorize(`
475
+ Auto-applying all changes...`, "green"));
476
+ return {
477
+ filesToApply: changes.map((c) => c.path),
478
+ filesToDiscard: []
479
+ };
480
+ }
481
+ if (autoDiscard) {
482
+ console.log(colorize(`
483
+ Auto-discarding all changes...`, "yellow"));
484
+ return {
485
+ filesToApply: [],
486
+ filesToDiscard: changes.map((c) => c.path)
487
+ };
488
+ }
489
+ console.log(`
490
+ What would you like to do?
491
+ `);
492
+ console.log(" [a] Apply all changes");
493
+ console.log(" [d] Discard all changes");
494
+ console.log(" [r] Review changes interactively");
495
+ console.log(" [s] Select files to apply");
496
+ console.log(" [q] Quit (discard all)");
497
+ const answer = await prompt(`
498
+ > `);
499
+ let filesToApply = [];
500
+ switch (answer) {
501
+ case "a":
502
+ case "apply":
503
+ filesToApply = changes.map((c) => c.path);
504
+ break;
505
+ case "d":
506
+ case "discard":
507
+ case "q":
508
+ case "quit":
509
+ break;
510
+ case "r":
511
+ case "review":
512
+ filesToApply = await reviewInteractively(changes);
513
+ break;
514
+ case "s":
515
+ case "select":
516
+ filesToApply = await selectFiles(changes);
517
+ break;
518
+ default:
519
+ console.log("Invalid option, discarding all changes.");
520
+ }
521
+ const filesToDiscard = changes.map((c) => c.path).filter((p) => !filesToApply.includes(p));
522
+ return { filesToApply, filesToDiscard };
523
+ }
524
+
525
+ // src/apply.ts
526
+ import { join as join3, dirname } from "path";
527
+ import { cp as cp2, rm as rm2, lstat as lstat2, mkdir as mkdir2 } from "fs/promises";
528
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
529
+ var red = (s) => `\x1B[31m${s}\x1B[0m`;
530
+ async function isWhiteout(path) {
531
+ try {
532
+ const stats = await lstat2(path);
533
+ return stats.isCharacterDevice() && stats.rdev === 0;
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+ async function applyChanges(ctx, changes, filesToApply) {
539
+ const toApplySet = new Set(filesToApply);
540
+ let applied = 0;
541
+ let failed = 0;
542
+ for (const change of changes) {
543
+ if (!toApplySet.has(change.path))
544
+ continue;
545
+ const src = join3(ctx.upperDir, change.path);
546
+ const dst = join3(ctx.targetDir, change.path);
547
+ try {
548
+ if (change.type === "deleted" || await isWhiteout(src)) {
549
+ await rm2(dst, { recursive: true, force: true });
550
+ console.log(green(` ✓ Deleted ${change.path}`));
551
+ } else {
552
+ await mkdir2(dirname(dst), { recursive: true });
553
+ await cp2(src, dst, { force: true, recursive: true, preserveTimestamps: true });
554
+ console.log(green(` ✓ Applied ${change.path}`));
555
+ }
556
+ applied++;
557
+ } catch (err) {
558
+ const message = err instanceof Error ? err.message : String(err);
559
+ console.log(red(` ✗ Failed to apply ${change.path}: ${message}`));
560
+ failed++;
561
+ }
562
+ }
563
+ const discarded = changes.length - applied - failed;
564
+ console.log();
565
+ console.log(`Done! ${applied} files applied` + (discarded > 0 ? `, ${discarded} discarded` : "") + (failed > 0 ? `, ${failed} failed` : "") + ".");
566
+ }
567
+
568
+ // index.ts
569
+ var VERSION = "0.0.1";
570
+ var HELP = `
571
+ sandwrap - Run commands in a sandbox with filesystem isolation
572
+
573
+ Usage:
574
+ sandwrap [options] <command> [args...]
575
+
576
+ Options:
577
+ -n, --no-network Disable network access inside sandbox
578
+ -k, --keep Keep overlay directory after exit (for debugging)
579
+ -y, --auto-apply Apply all changes without prompting
580
+ -d, --auto-discard Discard all changes without prompting
581
+ -h, --help Show this help message
582
+ -v, --version Show version
583
+
584
+ Examples:
585
+ sandwrap claude
586
+ sandwrap npm run build
587
+ sandwrap --no-network ./untrusted-script.sh
588
+
589
+ After the command exits, you can review filesystem changes and
590
+ selectively apply or discard them.
591
+ `;
592
+ function printHelp() {
593
+ console.log(HELP.trim());
594
+ }
595
+ function printVersion() {
596
+ console.log(`sandwrap v${VERSION}`);
597
+ }
598
+ function parseCliArgs() {
599
+ try {
600
+ const { values, positionals } = parseArgs({
601
+ args: process.argv.slice(2),
602
+ options: {
603
+ "no-network": { type: "boolean", short: "n", default: false },
604
+ keep: { type: "boolean", short: "k", default: false },
605
+ "auto-apply": { type: "boolean", short: "y", default: false },
606
+ "auto-discard": { type: "boolean", short: "d", default: false },
607
+ help: { type: "boolean", short: "h", default: false },
608
+ version: { type: "boolean", short: "v", default: false }
609
+ },
610
+ allowPositionals: true,
611
+ strict: true
612
+ });
613
+ if (values.help) {
614
+ printHelp();
615
+ process.exit(0);
616
+ }
617
+ if (values.version) {
618
+ printVersion();
619
+ process.exit(0);
620
+ }
621
+ if (positionals.length === 0) {
622
+ console.error(`Error: No command specified.
623
+ `);
624
+ printHelp();
625
+ process.exit(1);
626
+ }
627
+ if (values["auto-apply"] && values["auto-discard"]) {
628
+ console.error("Error: Cannot use both --auto-apply and --auto-discard.");
629
+ process.exit(1);
630
+ }
631
+ return {
632
+ command: positionals,
633
+ noNetwork: values["no-network"] ?? false,
634
+ keep: values.keep ?? false,
635
+ autoApply: values["auto-apply"] ?? false,
636
+ autoDiscard: values["auto-discard"] ?? false
637
+ };
638
+ } catch (err) {
639
+ const message = err instanceof Error ? err.message : String(err);
640
+ console.error(`Error: ${message}`);
641
+ process.exit(1);
642
+ }
643
+ }
644
+ async function main() {
645
+ if (process.env.SANDWRAP) {
646
+ console.error("Error: Already running inside a sandwrap sandbox.");
647
+ console.error(`Sandbox ID: ${process.env.SANDWRAP_ID}`);
648
+ process.exit(1);
649
+ }
650
+ const options = parseCliArgs();
651
+ if (!options)
652
+ return;
653
+ await checkDependencies();
654
+ const targetDir = process.cwd();
655
+ let ctx = null;
656
+ const cleanup = async () => {
657
+ if (ctx && !options.keep) {
658
+ console.log(`
659
+ Cleaning up sandbox...`);
660
+ await cleanupSandbox(ctx);
661
+ }
662
+ process.exit(130);
663
+ };
664
+ process.on("SIGINT", cleanup);
665
+ process.on("SIGTERM", cleanup);
666
+ try {
667
+ ctx = await createSandbox(targetDir);
668
+ console.log(`sandwrap: Starting sandbox (id: ${ctx.id})`);
669
+ console.log(`sandwrap: Running: ${options.command.join(" ")}`);
670
+ console.log();
671
+ const exitCode = await runInSandbox(ctx, options);
672
+ console.log();
673
+ const changes = await detectChanges(ctx);
674
+ const result = await reviewChanges(changes, exitCode, options.autoApply, options.autoDiscard);
675
+ if (result.filesToApply.length > 0) {
676
+ console.log();
677
+ await applyChanges(ctx, changes, result.filesToApply);
678
+ } else if (changes.length > 0) {
679
+ console.log(`
680
+ No changes applied.`);
681
+ }
682
+ } catch (err) {
683
+ const message = err instanceof Error ? err.message : String(err);
684
+ console.error(`Error: ${message}`);
685
+ process.exit(1);
686
+ } finally {
687
+ if (ctx) {
688
+ if (options.keep) {
689
+ console.log(`
690
+ Keeping overlay directory: ${ctx.tempDir}`);
691
+ } else {
692
+ await cleanupSandbox(ctx);
693
+ }
694
+ }
695
+ }
696
+ }
697
+ main();
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "sandwrap",
3
+ "version": "0.0.1",
4
+ "description": "Run commands in a sandbox with filesystem isolation using bubblewrap",
5
+ "type": "module",
6
+ "bin": {
7
+ "sandwrap": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "bun build index.ts --outdir dist --target node --format esm && { echo '#!/usr/bin/env node'; cat dist/index.js; } > dist/index.tmp && mv dist/index.tmp dist/index.js && chmod +x dist/index.js",
14
+ "prepublishOnly": "bun run build",
15
+ "start": "bun index.ts",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "keywords": [
19
+ "sandbox",
20
+ "bubblewrap",
21
+ "bwrap",
22
+ "isolation",
23
+ "security",
24
+ "filesystem"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "os": [
31
+ "linux"
32
+ ],
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "@types/node": "^25.0.3"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ }
40
+ }