bx-mac 0.1.5

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +194 -0
  3. package/dist/bx.js +204 -0
  4. package/package.json +33 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dirk Holtwick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # bx
2
+
3
+ Launch VSCode, a terminal, Claude Code, or any command in a macOS sandbox. Your tools can only see the project you're working on — not your private files, SSH keys, or other repositories.
4
+
5
+ ## Why?
6
+
7
+ AI-powered coding tools like Claude Code, Copilot, or Cline run with broad file system access. A misguided tool call or hallucinated path could accidentally read your SSH keys, credentials, tax documents, or private photos.
8
+
9
+ **bx** wraps any application in a macOS sandbox (`sandbox-exec`) that blocks access to everything except the project directory you explicitly specify. No containers, no VMs, no setup — just one command.
10
+
11
+ ## What it does
12
+
13
+ - Blocks access to `~/Documents`, `~/Desktop`, `~/Downloads`, and all other personal folders
14
+ - Blocks access to sibling projects — only the directory you specify is accessible
15
+ - Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo` by default
16
+ - Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
17
+ - Generates sandbox rules dynamically based on your actual `$HOME` contents
18
+ - Supports `.bxignore` to hide secrets like `.env` files within a project
19
+ - Supports `~/.bxallow` to grant access to shared utility directories
20
+
21
+ ## What it doesn't do
22
+
23
+ - **No network restrictions** — API calls, git push/pull, npm install all work normally
24
+ - **No process isolation** — this is file-level sandboxing, not a container
25
+ - **No protection against root/sudo** — the sandbox applies to the user-level process
26
+ - **macOS only** — relies on `sandbox-exec` which is an Apple-specific technology
27
+ - **Not a security guarantee** — `sandbox-exec` is undocumented and may have limitations; treat this as a safety net, not a vault
28
+
29
+ ## Requirements
30
+
31
+ - macOS (tested on Sequoia / macOS 15)
32
+ - Node.js >= 22
33
+ - Visual Studio Code installed in `/Applications` (for `code` mode)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ # Homebrew
39
+ brew install holtwick/tap/bx
40
+
41
+ # npm
42
+ npm install -g bx-mac
43
+
44
+ # From source
45
+ git clone https://github.com/holtwick/bx-mac.git
46
+ cd bx-mac
47
+ pnpm install && pnpm build
48
+ pnpm link -g
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ # Launch VSCode with sandbox protection
55
+ bx ~/work/my-project
56
+ ```
57
+
58
+ That's it. VSCode opens with full access to `~/work/my-project` and nothing else.
59
+
60
+ ## Modes
61
+
62
+ ```bash
63
+ bx [workdir] # VSCode (default)
64
+ bx code [workdir] # VSCode (explicit)
65
+ bx term [workdir] # sandboxed login shell ($SHELL -l)
66
+ bx claude [workdir] # Claude Code CLI
67
+ bx exec [workdir] -- command [args...] # arbitrary command
68
+ ```
69
+
70
+ If no directory is given, the current directory is used.
71
+
72
+ ### Examples
73
+
74
+ ```bash
75
+ # Work on a project in a sandboxed terminal
76
+ bx term ~/work/my-project
77
+
78
+ # Let Claude Code work on a project without access to anything else
79
+ bx claude ~/work/my-project
80
+
81
+ # Run a script in a sandbox
82
+ bx exec ~/work/my-project -- python train.py
83
+
84
+ # VSCode with verbose output
85
+ bx --verbose ~/work/my-project
86
+ ```
87
+
88
+ ## Options
89
+
90
+ | Option | Description |
91
+ |---|---|
92
+ | `--verbose` | Print the generated sandbox profile to stderr |
93
+ | `--profile-sandbox` | Use an isolated VSCode profile (separate extensions and settings, `code` mode only) |
94
+
95
+ ## Configuration
96
+
97
+ bx uses three optional config files, all with the same format: one entry per line, `#` for comments.
98
+
99
+ ### `~/.bxallow`
100
+
101
+ Allow extra directories beyond the working directory. Paths are relative to `$HOME`.
102
+
103
+ ```gitignore
104
+ # Shared shell scripts and utilities
105
+ work/bin
106
+ # Shared libraries used across projects
107
+ shared/libs
108
+ ```
109
+
110
+ ### `~/.bxignore`
111
+
112
+ Block additional dotdirs or files in your home. Paths are relative to `$HOME`.
113
+
114
+ ```gitignore
115
+ # Cloud provider credentials
116
+ .aws
117
+ .azure
118
+ .kube
119
+ .config/gcloud
120
+ ```
121
+
122
+ These are blocked **in addition** to the built-in protected list:
123
+
124
+ > `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
125
+
126
+ ### `<project>/.bxignore`
127
+
128
+ Block paths within the working directory itself. Supports glob patterns.
129
+
130
+ ```gitignore
131
+ # Environment files with secrets
132
+ .env
133
+ .env.*
134
+
135
+ # Credentials and keys
136
+ secrets/
137
+ **/*.pem
138
+ **/*.key
139
+ ```
140
+
141
+ ## How it works
142
+
143
+ bx generates a macOS sandbox profile at launch time:
144
+
145
+ 1. **Scan** `$HOME` for non-hidden directories
146
+ 2. **Block** each one individually with a `(deny file* (subpath ...))` rule
147
+ 3. **Skip** the working directory, `~/Library`, dotfiles, and any paths from `~/.bxallow`
148
+ 4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules — you can't deny a parent and allow a child)
149
+ 5. **Append** deny rules for protected dotdirs and `.bxignore` entries
150
+ 6. **Write** the profile to `/tmp`, launch VSCode via `sandbox-exec`, clean up on exit
151
+
152
+ ### Why not a simple deny-all + allow?
153
+
154
+ Apple's Sandbox Profile Language (SBPL) has a critical quirk: **`deny` always wins over `allow`**, regardless of rule order. This means:
155
+
156
+ ```scheme
157
+ ;; Does NOT work — the deny still blocks myproject
158
+ (deny file* (subpath "/Users/me/work"))
159
+ (allow file* (subpath "/Users/me/work/myproject"))
160
+ ```
161
+
162
+ Additionally, a broad `(deny file* (subpath HOME))` breaks `kqueue`/FSEvents file watchers and SQLite `fcntl` locks, causing VSCode errors even for paths that should be allowed.
163
+
164
+ bx avoids both issues by **never denying a parent of an allowed path**. Instead, it walks the directory tree and denies only the specific siblings that should be blocked.
165
+
166
+ ## Tips
167
+
168
+ **See what's happening:** Use `--verbose` to inspect the generated profile before trusting it with sensitive work.
169
+
170
+ **Test the sandbox:** Try reading a blocked file from VSCode's terminal:
171
+
172
+ ```bash
173
+ cat ~/Documents/something.txt # Should fail with "Operation not permitted"
174
+ cat ~/Desktop/file.txt # Should fail
175
+ ls ~/work/other-project/ # Should fail
176
+ ```
177
+
178
+ ## Safety checks
179
+
180
+ bx detects and prevents problematic scenarios:
181
+
182
+ - **Sandbox nesting:** If `CODEBOX_SANDBOX=1` is set (automatically passed to child processes), bx refuses to start — nested `sandbox-exec` causes silent failures.
183
+ - **Unknown sandbox:** On startup, bx probes `~/Documents`, `~/Desktop`, and `~/Downloads`. If any return `EPERM`, another sandbox is already active — bx aborts.
184
+ - **VSCode terminal:** If `VSCODE_PID` is set, bx warns that it will launch a *new* instance, not sandbox the current one.
185
+
186
+ ## Known limitations
187
+
188
+ - **File watcher warnings:** VSCode may log `EPERM` errors for `fs.watch()` on some paths. These are cosmetic and don't affect functionality.
189
+ - **SQLite warnings:** `state.vscdb` errors may appear in logs when VSCode's state database paths are affected. Extensions still work correctly.
190
+ - **`sandbox-exec` is undocumented:** Apple provides no official documentation for SBPL. The tool works in practice but could change with OS updates.
191
+
192
+ ## License
193
+
194
+ MIT — see [LICENSE](LICENSE).
package/dist/bx.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ //#region src/index.ts
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ if (process.env.CODEBOX_SANDBOX === "1") {
9
+ console.error("sandbox: ERROR — already running inside a bx sandbox.");
10
+ console.error("sandbox: Nesting sandbox-exec causes silent failures. Aborting.");
11
+ process.exit(1);
12
+ }
13
+ if (process.env.VSCODE_PID) {
14
+ console.error("sandbox: WARNING — running from inside a VSCode terminal.");
15
+ console.error("sandbox: This will launch a *new* instance in a sandbox.");
16
+ console.error("sandbox: The current VSCode instance will NOT be sandboxed.");
17
+ }
18
+ function isAlreadySandboxed() {
19
+ for (const dir of [
20
+ "Documents",
21
+ "Desktop",
22
+ "Downloads"
23
+ ]) {
24
+ const target = join(process.env.HOME, dir);
25
+ try {
26
+ accessSync(target, constants.R_OK);
27
+ } catch (e) {
28
+ if (e.code === "EPERM") return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ if (isAlreadySandboxed()) {
34
+ console.error("sandbox: ERROR — already running inside a sandbox!");
35
+ console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
36
+ process.exit(1);
37
+ }
38
+ const MODES = [
39
+ "code",
40
+ "term",
41
+ "claude",
42
+ "exec"
43
+ ];
44
+ const rawArgs = process.argv.slice(2);
45
+ const verbose = rawArgs.includes("--verbose");
46
+ const profileSandbox = rawArgs.includes("--profile-sandbox");
47
+ const positional = rawArgs.filter((a) => !a.startsWith("--"));
48
+ const doubleDashIdx = rawArgs.indexOf("--");
49
+ const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
50
+ const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
51
+ let mode = "code";
52
+ let workArg = ".";
53
+ if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
54
+ mode = beforeDash[0];
55
+ workArg = beforeDash[1] ?? ".";
56
+ } else if (beforeDash.length > 0) workArg = beforeDash[0];
57
+ if (mode === "exec" && execCmd.length === 0) {
58
+ console.error("sandbox: exec mode requires a command after \"--\"");
59
+ console.error("usage: bx exec [workdir] -- command [args...]");
60
+ process.exit(1);
61
+ }
62
+ const HOME = process.env.HOME;
63
+ const SCRIPT_DIR = __dirname;
64
+ const WORK_DIR = resolve(workArg);
65
+ const allowedDirs = new Set([WORK_DIR]);
66
+ const sandboxAllowPath = join(HOME, ".bxallow");
67
+ if (existsSync(sandboxAllowPath)) for (const raw of readFileSync(sandboxAllowPath, "utf-8").split("\n")) {
68
+ const line = raw.trim();
69
+ if (!line || line.startsWith("#")) continue;
70
+ const absolute = resolve(HOME, line);
71
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) allowedDirs.add(absolute);
72
+ }
73
+ const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
74
+ const VSCODE_DATA = join(HOME, ".vscode-sandbox");
75
+ const VSCODE_EXTENSIONS_GLOBAL = join(HOME, ".vscode", "extensions");
76
+ const VSCODE_EXTENSIONS_LOCAL = join(VSCODE_DATA, "extensions");
77
+ if (mode === "code" && profileSandbox) {
78
+ mkdirSync(VSCODE_DATA, { recursive: true });
79
+ if (!existsSync(VSCODE_EXTENSIONS_LOCAL) && existsSync(VSCODE_EXTENSIONS_GLOBAL)) {
80
+ console.error("sandbox: copying extensions from global install...");
81
+ cpSync(VSCODE_EXTENSIONS_GLOBAL, VSCODE_EXTENSIONS_LOCAL, { recursive: true });
82
+ }
83
+ }
84
+ function collectBlockedDirs(parentDir) {
85
+ const blocked = [];
86
+ for (const name of readdirSync(parentDir)) {
87
+ if (name.startsWith(".")) continue;
88
+ const fullPath = join(parentDir, name);
89
+ if (!statSync(fullPath).isDirectory()) continue;
90
+ if (parentDir === HOME && name === "Library") continue;
91
+ if (SCRIPT_DIR.startsWith(fullPath + "/") || SCRIPT_DIR === fullPath) continue;
92
+ if (allowedDirs.has(fullPath)) continue;
93
+ if ([...allowedDirs].some((d) => d.startsWith(fullPath + "/"))) {
94
+ blocked.push(...collectBlockedDirs(fullPath));
95
+ continue;
96
+ }
97
+ blocked.push(fullPath);
98
+ }
99
+ return blocked;
100
+ }
101
+ const blockedDirs = collectBlockedDirs(HOME);
102
+ const PROTECTED_DOTDIRS = [
103
+ ".Trash",
104
+ ".ssh",
105
+ ".gnupg",
106
+ ".docker",
107
+ ".zsh_sessions",
108
+ ".cargo",
109
+ ".gradle",
110
+ ".gem"
111
+ ];
112
+ const ignoredPaths = PROTECTED_DOTDIRS.map((d) => join(HOME, d));
113
+ function parseSandboxIgnore(filePath, baseDir) {
114
+ if (!existsSync(filePath)) return;
115
+ for (const raw of readFileSync(filePath, "utf-8").split("\n")) {
116
+ const line = raw.trim();
117
+ if (!line || line.startsWith("#")) continue;
118
+ const matches = globSync(line, { cwd: baseDir });
119
+ for (const match of matches) ignoredPaths.push(resolve(baseDir, match));
120
+ }
121
+ }
122
+ parseSandboxIgnore(join(HOME, ".bxignore"), HOME);
123
+ parseSandboxIgnore(join(WORK_DIR, ".bxignore"), WORK_DIR);
124
+ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
125
+ if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
126
+ const profile = `; Auto-generated sandbox profile
127
+ ; Working directory: ${WORK_DIR}
128
+
129
+ (version 1)
130
+ (allow default)
131
+
132
+ ; Blocked directories (auto-generated from $HOME contents)
133
+ (deny file*
134
+ ${blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}
135
+ )
136
+
137
+ ${ignoredPaths.length > 0 ? `
138
+ ; Hidden paths from .bxignore
139
+ (deny file*
140
+ ${ignoredPaths.map((p) => {
141
+ return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
142
+ }).join("\n")}
143
+ )
144
+ ` : ""}
145
+ `;
146
+ const profilePath = join("/tmp", `bx-${process.pid}.sb`);
147
+ writeFileSync(profilePath, profile);
148
+ console.error(`sandbox: ${mode} mode, working directory: ${WORK_DIR}`);
149
+ if (verbose) {
150
+ console.error("\n--- Generated sandbox profile ---");
151
+ console.error(profile);
152
+ console.error("--- End of profile ---\n");
153
+ }
154
+ function buildCommand() {
155
+ switch (mode) {
156
+ case "code": {
157
+ const args = ["--no-sandbox"];
158
+ if (profileSandbox) {
159
+ args.push("--user-data-dir", join(VSCODE_DATA, "data"));
160
+ args.push("--extensions-dir", VSCODE_EXTENSIONS_LOCAL);
161
+ }
162
+ args.push(WORK_DIR);
163
+ return {
164
+ bin: VSCODE_APP,
165
+ args
166
+ };
167
+ }
168
+ case "term": return {
169
+ bin: process.env.SHELL ?? "/bin/zsh",
170
+ args: ["-l"]
171
+ };
172
+ case "claude": return {
173
+ bin: "claude",
174
+ args: [WORK_DIR]
175
+ };
176
+ case "exec": return {
177
+ bin: execCmd[0],
178
+ args: execCmd.slice(1)
179
+ };
180
+ }
181
+ }
182
+ const cmd = buildCommand();
183
+ spawn("sandbox-exec", [
184
+ "-f",
185
+ profilePath,
186
+ "-D",
187
+ `HOME=${HOME}`,
188
+ "-D",
189
+ `WORK=${WORK_DIR}`,
190
+ cmd.bin,
191
+ ...cmd.args
192
+ ], {
193
+ cwd: WORK_DIR,
194
+ stdio: "inherit",
195
+ env: {
196
+ ...process.env,
197
+ CODEBOX_SANDBOX: "1"
198
+ }
199
+ }).on("close", (code) => {
200
+ rmSync(profilePath, { force: true });
201
+ process.exit(code ?? 0);
202
+ });
203
+ //#endregion
204
+ export {};
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "bx-mac",
3
+ "version": "0.1.5",
4
+ "description": "Launch apps in a macOS sandbox — only the project directory is accessible",
5
+ "type": "module",
6
+ "bin": {
7
+ "bx": "dist/bx.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "rolldown -c",
14
+ "release": "./scripts/release.sh",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "sandbox",
19
+ "macos",
20
+ "security",
21
+ "vscode",
22
+ "claude"
23
+ ],
24
+ "author": "Dirk Holtwick",
25
+ "license": "MIT",
26
+ "devDependencies": {
27
+ "rolldown": "^1.0.0-rc.12"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "packageManager": "pnpm@10.33.0"
33
+ }