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.
- package/README.md +385 -0
- package/dist/index.js +697 -0
- 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
|
+
}
|