sandwrap
v0.0.3
Published
Run commands in a sandbox with filesystem isolation using bubblewrap
Maintainers
Readme
sandwrap
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.
Usage
bunx sandwrap <command> [args...]
# Examples
bunx sandwrap claude
bunx sandwrap claude code
bunx sandwrap ./my-agent.sh
bunx sandwrap npm run dangerous-scriptHow 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 <command> 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
Main Command
sandwrap <command> [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 versionPost-Execution Review
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)
> Interactive Review Mode (r)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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
> Select Files Mode (s)
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.mdCore Module: sandbox.ts
Responsible for:
Creating overlay structure
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 }Building bwrap command
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 \ -- <command> [args...]Alternative: fuse-overlayfs for unprivileged users
If user lacks permissions for kernel overlayfs, fall back to fuse-overlayfs:
fuse-overlayfs -o lowerdir=$CWD,upperdir=$UPPER,workdir=$WORK $MERGED
Core Module: diff.ts
Responsible for detecting and formatting changes:
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;Change Detection Logic
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)
// Detect whiteout files (overlayfs deletion markers) const stats = await fs.lstat(path); const isWhiteout = stats.isCharacterDevice() && stats.rdev === 0;
Core Module: review.ts
Interactive terminal UI using something like @clack/prompts or raw readline:
interface ReviewResult {
filesToApply: string[];
filesToDiscard: string[];
}
async function reviewChanges(changes: FileChange[]): Promise<ReviewResult>;Core Module: apply.ts
Applies selected changes from upper directory to original:
async function applyChanges(
ctx: SandboxContext,
files: string[]
): Promise<void> {
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
{
"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 asbubblewrappackagefuse-overlayfs(optional fallback for unprivileged overlay)
Check on startup:
async function checkDependencies(): Promise<void> {
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
- Binary files: Show as changed but don't display diff content
- Symlinks: Preserve symlink targets when applying
- Permissions: Preserve file modes when copying
- Large files: Stream diff rather than loading into memory
- Nested sandboxing: Detect if already in sandbox and warn/fail
- Signal handling: Clean up overlay on SIGINT/SIGTERM
- Unprivileged users: Fall back to fuse-overlayfs if needed
- macOS: bubblewrap is Linux-only; would need alternative (maybe lima/colima + bwrap inside)
Example Session
$ 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
--snapshotmode: Save overlay as tarball for later replay--comparemode: 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:
.sandwraprcfor default options per project
