Run commands in a sandbox with filesystem isolation using bubblewrap
npm install sandwrapA 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.
``bash
bunx sandwrap
How It Works
`
┌─────────────────────────────────────────────────────────────┐
│ sandwrap process │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Setup Phase │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Create temp directory structure: │ │
│ │ /tmp/sandwrap-XXXX/ │ │
│ │ ├── upper/ (overlay writes go here) │ │
│ │ ├── work/ (overlayfs workdir) │ │
│ │ └── merged/ (union mount point) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 2. Execution Phase │
│ ┌─────────────────────────────────────────────────┐ │
│ │ bwrap invocation: │ │
│ │ - Mount CWD as lower (read-only) │ │
│ │ - Mount upper + lower as overlay │ │
│ │ - Bind essential dirs (/usr, /bin, etc.) │ │
│ │ - Run inside sandbox │ │
│ │ - All writes captured in upper/ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 3. Review Phase (after command exits) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ - Scan upper/ for changes │ │
│ │ - Generate diffs for modified files │ │
│ │ - Show new/deleted files │ │
│ │ - Interactive prompt: apply/discard/review │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
`CLI Interface
$3
`
sandwrap [args...]Options:
--no-network, -n Disable network access inside sandbox
--keep, -k Keep overlay directory after exit (for debugging)
--auto-apply, -y Apply all changes without prompting
--auto-discard, -d Discard all changes without prompting
--help, -h Show help
--version, -v Show version
`$3
After the sandboxed command exits, sandwrap enters interactive review mode:
`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
sandwrap: command exited with code 0
Changes detected:
M src/index.ts (+42, -13)
M package.json (+2, -1)
A src/utils/new.ts (+87)
D old-config.json
Total: 2 modified, 1 added, 1 deleted
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━What would you like to do?
[a] Apply all changes
[d] Discard all changes
[r] Review changes interactively
[s] Select files to apply
[q] Quit (discard all)
>
`$3
`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Reviewing: src/index.ts (1/4)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--- a/src/index.ts
+++ b/src/index.ts
@@ -10,6 +10,12 @@ import { foo } from './foo';
export function main() {
+ // Added safety check
+ if (!validateInput(input)) {
+ throw new Error('Invalid input');
+ }
+
const result = process(input);
return result;
}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[y] Apply this file [n] Skip [v] View full file
[e] Edit before applying [q] Quit review
>
`$3
`
Select files to apply (space to toggle, enter to confirm): [x] M src/index.ts
[ ] M package.json
[x] A src/utils/new.ts
[ ] D old-config.json
>
`Architecture
`
sandwrap/
├── package.json
├── src/
│ ├── index.ts # Entry point & CLI parsing
│ ├── sandbox.ts # bwrap + overlay setup/teardown
│ ├── diff.ts # Change detection & diff generation
│ ├── review.ts # Interactive review UI
│ └── apply.ts # File operations to apply changes
└── README.md
`Core Module:
sandbox.tsResponsible for:
1. Creating overlay structure
`typescript
interface SandboxContext {
id: string;
tempDir: string;
upperDir: string; // Where writes go
workDir: string; // OverlayFS requirement
mergedDir: string; // Union mount point
targetDir: string; // Original CWD being sandboxed
}
`2. Building bwrap command
`bash
bwrap \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /bin /bin \
--ro-bind /etc /etc \
--dev /dev \
--proc /proc \
--tmpfs /tmp \
--bind $UPPER $CWD \ # Overlay writes go to upper
--ro-bind $CWD $CWD/.lower # Read-only access to original
--chdir $CWD \
--unshare-user \
--unshare-pid \
--unshare-net \ # If --no-network
--die-with-parent \
-- [args...]
`3. Alternative: fuse-overlayfs for unprivileged users
If user lacks permissions for kernel overlayfs, fall back to fuse-overlayfs:
`bash
fuse-overlayfs -o lowerdir=$CWD,upperdir=$UPPER,workdir=$WORK $MERGED
`Core Module:
diff.tsResponsible for detecting and formatting changes:
`typescript
interface FileChange {
path: string;
type: 'added' | 'modified' | 'deleted';
diff?: string; // Unified diff for text files
linesAdded?: number;
linesRemoved?: number;
isBinary: boolean;
}// Scan upper directory for changes
function detectChanges(ctx: SandboxContext): FileChange[];
// Generate unified diff between original and modified
function generateDiff(original: string, modified: string): string;
`$3
OverlayFS marks changes in the upper directory:
- New files: Present in upper, not in lower
- Modified files: Present in both (upper shadows lower)
- Deleted files: Character device with 0/0 major/minor (whiteout)
`typescript
// Detect whiteout files (overlayfs deletion markers)
const stats = await fs.lstat(path);
const isWhiteout = stats.isCharacterDevice() &&
stats.rdev === 0;
`Core Module:
review.tsInteractive terminal UI using something like
@clack/prompts or raw readline:`typescript
interface ReviewResult {
filesToApply: string[];
filesToDiscard: string[];
}async function reviewChanges(changes: FileChange[]): Promise;
`Core Module:
apply.tsApplies selected changes from upper directory to original:
`typescript
async function applyChanges(
ctx: SandboxContext,
files: string[]
): Promise {
for (const file of files) {
const src = path.join(ctx.upperDir, file);
const dst = path.join(ctx.targetDir, file);
const stats = await fs.lstat(src);
if (isWhiteout(stats)) {
// Delete from original
await fs.rm(dst, { recursive: true });
} else {
// Copy from upper to original
await fs.cp(src, dst, { recursive: true });
}
}
}
`Dependencies
`json
{
"name": "sandwrap",
"version": "0.1.0",
"bin": {
"sandwrap": "./dist/index.js"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"diff": "^5.2.0",
"picocolors": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^20.0.0",
"@types/diff": "^5.0.0"
}
}
`Requirements
System dependencies (must be installed):
-
bwrap (bubblewrap) - Usually available as bubblewrap package
- fuse-overlayfs (optional fallback for unprivileged overlay)Check on startup:
`typescript
async function checkDependencies(): Promise {
try {
await execAsync('which bwrap');
} catch {
console.error('Error: bubblewrap not found.');
console.error('Install with: sudo apt install bubblewrap');
process.exit(1);
}
}
`Edge Cases & Considerations
1. Binary files: Show as changed but don't display diff content
2. Symlinks: Preserve symlink targets when applying
3. Permissions: Preserve file modes when copying
4. Large files: Stream diff rather than loading into memory
5. Nested sandboxing: Detect if already in sandbox and warn/fail
6. Signal handling: Clean up overlay on SIGINT/SIGTERM
7. Unprivileged users: Fall back to fuse-overlayfs if needed
8. macOS: bubblewrap is Linux-only; would need alternative (maybe lima/colima + bwrap inside)
Example Session
`bash
$ bunx sandwrap claude... claude runs, makes changes ...
User types /exit or Ctrl+D
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
sandwrap: claude exited with code 0
Changes detected:
M src/api.ts (+15, -3)
M src/types.ts (+8, -0)
A src/helpers/cache.ts (+45)
Total: 2 modified, 1 added, 0 deleted
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
What would you like to do?
[a] Apply all changes
[d] Discard all changes
[r] Review changes interactively
[s] Select files to apply
[q] Quit (discard all)
> r
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Reviewing: src/api.ts (1/3)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
--- a/src/api.ts
+++ b/src/api.ts
@@ -22,6 +22,18 @@ export async function fetchData(id: string) {
+ // Add caching layer
+ const cached = await cache.get(id);
+ if (cached) return cached;
+
const response = await fetch(
/api/data/${id});
- return response.json();
+ const data = await response.json();
+ await cache.set(id, data);
+ return data;
}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[y] Apply [n] Skip [v] View full [q] Quit
> y
✓ Applied src/api.ts
✓ Applied src/types.ts
✓ Applied src/helpers/cache.ts
Done! 3 files applied, 0 discarded.
`Future Enhancements
-
--snapshot mode: Save overlay as tarball for later replay
- --compare mode: Run same command with/without changes, compare output
- Git integration: Stage applied changes automatically
- Undo support: Keep backup of original files before applying
- Config file: .sandwraprc` for default options per project