bx-mac 0.1.5 → 0.3.0

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 +73 -83
  2. package/dist/bx.js +223 -109
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -1,38 +1,38 @@
1
- # bx
1
+ # 📦 bx
2
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.
3
+ > **Put your AI in a box.** Launch VSCode, Claude Code, a terminal, or any command in a macOS sandbox your tools can only see the project you're working on.
4
4
 
5
- ## Why?
5
+ ## 🤔 Why?
6
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.
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
8
 
9
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
10
 
11
- ## What it does
11
+ ```bash
12
+ bx ~/work/my-project
13
+ ```
14
+
15
+ That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing else.
12
16
 
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
17
+ ## What it does
20
18
 
21
- ## What it doesn't do
19
+ - 🔒 Blocks `~/Documents`, `~/Desktop`, `~/Downloads`, and all other personal folders
20
+ - 🚧 Blocks sibling projects — only the directory you specify is accessible
21
+ - 🛡️ Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo`
22
+ - ⚙️ Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
23
+ - 🔍 Generates sandbox rules dynamically based on your actual `$HOME` contents
24
+ - 📝 Supports `.bxignore` to hide secrets like `.env` files within a project
25
+ - 📂 Supports `~/.bxallow` to grant access to shared utility directories
26
+
27
+ ## 🚫 What it doesn't do
22
28
 
23
29
  - **No network restrictions** — API calls, git push/pull, npm install all work normally
24
30
  - **No process isolation** — this is file-level sandboxing, not a container
25
31
  - **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)
32
+ - **macOS only** — relies on `sandbox-exec` (Apple-specific)
33
+ - **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
34
34
 
35
- ## Install
35
+ ## 📥 Install
36
36
 
37
37
  ```bash
38
38
  # Homebrew
@@ -48,71 +48,65 @@ pnpm install && pnpm build
48
48
  pnpm link -g
49
49
  ```
50
50
 
51
- ## Quick start
51
+ **Requirements:** macOS (tested on Sequoia 15), Node.js >= 22
52
52
 
53
- ```bash
54
- # Launch VSCode with sandbox protection
55
- bx ~/work/my-project
56
- ```
53
+ ## 🚀 Modes
57
54
 
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
- ```
55
+ | Command | What it launches |
56
+ |---|---|
57
+ | `bx [workdir]` | 🖥️ VSCode (default) |
58
+ | `bx code [workdir]` | 🖥️ VSCode (explicit) |
59
+ | `bx term [workdir]` | 💻 Sandboxed login shell (`$SHELL -l`) |
60
+ | `bx claude [workdir]` | 🤖 Claude Code CLI |
61
+ | `bx exec [workdir] -- cmd` | ⚡ Any command you want |
69
62
 
70
63
  If no directory is given, the current directory is used.
71
64
 
72
65
  ### Examples
73
66
 
74
67
  ```bash
75
- # Work on a project in a sandboxed terminal
68
+ # 🖥️ VSCode with sandbox protection
69
+ bx ~/work/my-project
70
+
71
+ # 💻 Work on a project in a sandboxed terminal
76
72
  bx term ~/work/my-project
77
73
 
78
- # Let Claude Code work on a project without access to anything else
74
+ # 🤖 Let Claude Code work on a project nothing else visible
79
75
  bx claude ~/work/my-project
80
76
 
81
- # Run a script in a sandbox
77
+ # Run a script in a sandbox
82
78
  bx exec ~/work/my-project -- python train.py
83
79
 
84
- # VSCode with verbose output
80
+ # 🔍 See the generated sandbox profile
85
81
  bx --verbose ~/work/my-project
86
82
  ```
87
83
 
88
- ## Options
84
+ ## ⚙️ Options
89
85
 
90
86
  | Option | Description |
91
87
  |---|---|
92
88
  | `--verbose` | Print the generated sandbox profile to stderr |
93
- | `--profile-sandbox` | Use an isolated VSCode profile (separate extensions and settings, `code` mode only) |
89
+ | `--profile-sandbox` | Use an isolated VSCode profile (separate extensions/settings, `code` mode only) |
94
90
 
95
- ## Configuration
91
+ ## 📝 Configuration
96
92
 
97
- bx uses three optional config files, all with the same format: one entry per line, `#` for comments.
93
+ bx uses three optional config files one entry per line, `#` for comments.
98
94
 
99
95
  ### `~/.bxallow`
100
96
 
101
- Allow extra directories beyond the working directory. Paths are relative to `$HOME`.
97
+ Allow extra directories beyond the working directory. Paths relative to `$HOME`.
102
98
 
103
99
  ```gitignore
104
100
  # Shared shell scripts and utilities
105
101
  work/bin
106
- # Shared libraries used across projects
107
102
  shared/libs
108
103
  ```
109
104
 
110
105
  ### `~/.bxignore`
111
106
 
112
- Block additional dotdirs or files in your home. Paths are relative to `$HOME`.
107
+ Block additional dotdirs or files in your home. Paths relative to `$HOME`.
113
108
 
114
109
  ```gitignore
115
- # Cloud provider credentials
116
110
  .aws
117
111
  .azure
118
112
  .kube
@@ -121,74 +115,70 @@ Block additional dotdirs or files in your home. Paths are relative to `$HOME`.
121
115
 
122
116
  These are blocked **in addition** to the built-in protected list:
123
117
 
124
- > `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
118
+ > 🔒 `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
125
119
 
126
120
  ### `<project>/.bxignore`
127
121
 
128
- Block paths within the working directory itself. Supports glob patterns.
122
+ Block paths within the working directory. Supports glob patterns.
129
123
 
130
124
  ```gitignore
131
- # Environment files with secrets
132
125
  .env
133
126
  .env.*
134
-
135
- # Credentials and keys
136
127
  secrets/
137
128
  **/*.pem
138
129
  **/*.key
139
130
  ```
140
131
 
141
- ## How it works
132
+ ## 🔧 How it works
142
133
 
143
134
  bx generates a macOS sandbox profile at launch time:
144
135
 
145
136
  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)
137
+ 2. **Block** each one individually with `(deny file* (subpath ...))`
138
+ 3. **Skip** the working directory, `~/Library`, dotfiles, and `~/.bxallow` paths
139
+ 4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules)
149
140
  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
141
+ 6. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
151
142
 
152
143
  ### Why not a simple deny-all + allow?
153
144
 
154
- Apple's Sandbox Profile Language (SBPL) has a critical quirk: **`deny` always wins over `allow`**, regardless of rule order. This means:
145
+ Apple's SBPL has a critical quirk: **`deny` always wins over `allow`**, regardless of rule order:
155
146
 
156
147
  ```scheme
157
- ;; Does NOT work — the deny still blocks myproject
148
+ ;; Does NOT work — the deny still blocks myproject
158
149
  (deny file* (subpath "/Users/me/work"))
159
150
  (allow file* (subpath "/Users/me/work/myproject"))
160
151
  ```
161
152
 
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.
153
+ Additionally, a broad `(deny file* (subpath HOME))` breaks `kqueue`/FSEvents file watchers and SQLite locks, causing VSCode errors.
165
154
 
166
- ## Tips
155
+ bx avoids both issues by **never denying a parent of an allowed path** — it walks the directory tree and blocks only the specific siblings.
167
156
 
168
- **See what's happening:** Use `--verbose` to inspect the generated profile before trusting it with sensitive work.
157
+ ## 🛡️ Safety checks
169
158
 
170
- **Test the sandbox:** Try reading a blocked file from VSCode's terminal:
159
+ bx detects and prevents problematic scenarios:
171
160
 
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
- ```
161
+ - **🔄 Sandbox nesting:** If `CODEBOX_SANDBOX=1` is set (auto-propagated), bx refuses to start — nested sandboxes cause silent failures.
162
+ - **🔍 Unknown sandbox:** On startup, bx probes `~/Documents`, `~/Desktop`, `~/Downloads`. If any return `EPERM`, another sandbox is active — bx aborts.
163
+ - **⚠️ VSCode terminal:** If `VSCODE_PID` is set, bx warns that it will launch a *new* instance, not sandbox the current one.
177
164
 
178
- ## Safety checks
165
+ ## 💡 Tips
179
166
 
180
- bx detects and prevents problematic scenarios:
167
+ **Verify it works** try reading a blocked file from the sandboxed terminal:
181
168
 
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.
169
+ ```bash
170
+ cat ~/Documents/something.txt # Operation not permitted
171
+ cat ~/Desktop/file.txt # Operation not permitted
172
+ ls ~/work/other-project/ # ❌ Operation not permitted
173
+ cat ./src/index.ts # ✅ Works!
174
+ ```
185
175
 
186
- ## Known limitations
176
+ ## ⚠️ Known limitations
187
177
 
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.
178
+ - **File watcher warnings:** VSCode may log `EPERM` for `fs.watch()` on some paths cosmetic only
179
+ - **SQLite warnings:** `state.vscdb` errors may appear in logs extensions still work
180
+ - **`sandbox-exec` is undocumented:** Apple could change behavior with OS updates
191
181
 
192
- ## License
182
+ ## 📄 License
193
183
 
194
184
  MIT — see [LICENSE](LICENSE).
package/dist/bx.js CHANGED
@@ -1,21 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ const __VERSION__ = "0.3.0";
2
3
  import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { spawn } from "node:child_process";
5
6
  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);
7
+ import process from "node:process";
8
+ //#region src/guards.ts
9
+ /**
10
+ * Abort if we're already inside a bx sandbox (env var set by us).
11
+ */
12
+ function checkOwnSandbox() {
13
+ if (process.env.CODEBOX_SANDBOX === "1") {
14
+ console.error("sandbox: ERROR — already running inside a bx sandbox.");
15
+ console.error("sandbox: Nesting sandbox-exec causes silent failures. Aborting.");
16
+ process.exit(1);
17
+ }
12
18
  }
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.");
19
+ /**
20
+ * Warn if launched from inside a VSCode terminal.
21
+ */
22
+ function checkVSCodeTerminal() {
23
+ if (process.env.VSCODE_PID) {
24
+ console.error("sandbox: WARNING — running from inside a VSCode terminal.");
25
+ console.error("sandbox: This will launch a *new* instance in a sandbox.");
26
+ console.error("sandbox: The current VSCode instance will NOT be sandboxed.");
27
+ }
17
28
  }
18
- function isAlreadySandboxed() {
29
+ /**
30
+ * Detect if we're inside an unknown sandbox by probing well-known
31
+ * directories that exist on every Mac but would be blocked.
32
+ */
33
+ function checkExternalSandbox() {
19
34
  for (const dir of [
20
35
  "Documents",
21
36
  "Desktop",
@@ -25,141 +40,187 @@ function isAlreadySandboxed() {
25
40
  try {
26
41
  accessSync(target, constants.R_OK);
27
42
  } catch (e) {
28
- if (e.code === "EPERM") return true;
43
+ if (e.code === "EPERM") {
44
+ console.error("sandbox: ERROR — already running inside a sandbox!");
45
+ console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
46
+ process.exit(1);
47
+ }
29
48
  }
30
49
  }
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
50
  }
51
+ //#endregion
52
+ //#region src/args.ts
38
53
  const MODES = [
39
54
  "code",
40
55
  "term",
41
56
  "claude",
42
57
  "exec"
43
58
  ];
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);
59
+ function parseArgs() {
60
+ const rawArgs = process.argv.slice(2);
61
+ const verbose = rawArgs.includes("--verbose");
62
+ const profileSandbox = rawArgs.includes("--profile-sandbox");
63
+ const positional = rawArgs.filter((a) => !a.startsWith("--"));
64
+ const doubleDashIdx = rawArgs.indexOf("--");
65
+ const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
66
+ const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
67
+ let mode = "code";
68
+ let workArgs;
69
+ if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
70
+ mode = beforeDash[0];
71
+ workArgs = beforeDash.slice(1);
72
+ } else workArgs = beforeDash;
73
+ if (workArgs.length === 0) workArgs = ["."];
74
+ if (mode === "exec" && execCmd.length === 0) {
75
+ console.error("sandbox: exec mode requires a command after \"--\"");
76
+ console.error("usage: bx exec [workdir...] -- command [args...]");
77
+ process.exit(1);
78
+ }
79
+ return {
80
+ mode,
81
+ workArgs,
82
+ verbose,
83
+ profileSandbox,
84
+ execCmd
85
+ };
61
86
  }
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);
87
+ //#endregion
88
+ //#region src/profile.ts
89
+ const PROTECTED_DOTDIRS = [
90
+ ".Trash",
91
+ ".ssh",
92
+ ".gnupg",
93
+ ".docker",
94
+ ".zsh_sessions",
95
+ ".cargo",
96
+ ".gradle",
97
+ ".gem"
98
+ ];
99
+ /**
100
+ * Parse a config file with one entry per line (supports # comments).
101
+ */
102
+ function parseLines(filePath) {
103
+ if (!existsSync(filePath)) return [];
104
+ return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
72
105
  }
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 });
106
+ /**
107
+ * Apply a single .bxignore file: resolve glob patterns relative to baseDir.
108
+ */
109
+ function applyIgnoreFile(filePath, baseDir, ignored) {
110
+ for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
111
+ }
112
+ /**
113
+ * Recursively find and apply .bxignore files in a directory tree.
114
+ */
115
+ function collectIgnoreFilesRecursive(dir, ignored) {
116
+ const ignoreFile = join(dir, ".bxignore");
117
+ if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(dir);
121
+ } catch {
122
+ return;
82
123
  }
124
+ for (const name of entries) {
125
+ if (name.startsWith(".") || name === "node_modules") continue;
126
+ const fullPath = join(dir, name);
127
+ try {
128
+ if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
129
+ } catch {}
130
+ }
131
+ }
132
+ /**
133
+ * Parse ~/.bxallow and return a set of all allowed directories.
134
+ */
135
+ function parseAllowedDirs(home, workDirs) {
136
+ const allowed = new Set(workDirs);
137
+ for (const line of parseLines(join(home, ".bxallow"))) {
138
+ const absolute = resolve(home, line);
139
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
140
+ }
141
+ return allowed;
83
142
  }
84
- function collectBlockedDirs(parentDir) {
143
+ /**
144
+ * Recursively collect directories to block under parentDir.
145
+ * Never blocks a parent of an allowed path — instead descends and blocks siblings.
146
+ */
147
+ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
85
148
  const blocked = [];
86
149
  for (const name of readdirSync(parentDir)) {
87
150
  if (name.startsWith(".")) continue;
88
151
  const fullPath = join(parentDir, name);
89
152
  if (!statSync(fullPath).isDirectory()) continue;
90
- if (parentDir === HOME && name === "Library") continue;
91
- if (SCRIPT_DIR.startsWith(fullPath + "/") || SCRIPT_DIR === fullPath) continue;
153
+ if (parentDir === home && name === "Library") continue;
154
+ if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
92
155
  if (allowedDirs.has(fullPath)) continue;
93
156
  if ([...allowedDirs].some((d) => d.startsWith(fullPath + "/"))) {
94
- blocked.push(...collectBlockedDirs(fullPath));
157
+ blocked.push(...collectBlockedDirs(fullPath, home, scriptDir, allowedDirs));
95
158
  continue;
96
159
  }
97
160
  blocked.push(fullPath);
98
161
  }
99
162
  return blocked;
100
163
  }
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
- }
164
+ /**
165
+ * Collect paths to deny from .bxignore files and built-in protected dotdirs.
166
+ * Searches ~/.bxignore and recursively through all workdirs.
167
+ */
168
+ function collectIgnoredPaths(home, workDirs) {
169
+ const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
170
+ applyIgnoreFile(join(home, ".bxignore"), home, ignored);
171
+ for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
172
+ return ignored;
121
173
  }
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}
174
+ /**
175
+ * Generate the SBPL sandbox profile string.
176
+ */
177
+ function generateProfile(workDirs, blockedDirs, ignoredPaths) {
178
+ const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
179
+ const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
180
+ return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
181
+ }).join("\n")}\n)\n` : "";
182
+ return `; Auto-generated sandbox profile
183
+ ; Working directories: ${workDirs.join(", ")}
128
184
 
129
185
  (version 1)
130
186
  (allow default)
131
187
 
132
188
  ; Blocked directories (auto-generated from $HOME contents)
133
189
  (deny file*
134
- ${blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}
190
+ ${denyRules}
135
191
  )
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
- ` : ""}
192
+ ${ignoredRules}
145
193
  `;
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
194
  }
154
- function buildCommand() {
195
+ //#endregion
196
+ //#region src/modes.ts
197
+ const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
198
+ /**
199
+ * Prepare VSCode isolated profile if --profile-sandbox is set.
200
+ */
201
+ function setupVSCodeProfile(home) {
202
+ const dataDir = join(home, ".vscode-sandbox");
203
+ const globalExt = join(home, ".vscode", "extensions");
204
+ const localExt = join(dataDir, "extensions");
205
+ mkdirSync(dataDir, { recursive: true });
206
+ if (!existsSync(localExt) && existsSync(globalExt)) {
207
+ console.error("sandbox: copying extensions from global install...");
208
+ cpSync(globalExt, localExt, { recursive: true });
209
+ }
210
+ }
211
+ /**
212
+ * Build the command + args to run inside the sandbox for the given mode.
213
+ */
214
+ function buildCommand(mode, workDirs, home, profileSandbox, execCmd) {
155
215
  switch (mode) {
156
216
  case "code": {
217
+ const dataDir = join(home, ".vscode-sandbox");
157
218
  const args = ["--no-sandbox"];
158
219
  if (profileSandbox) {
159
- args.push("--user-data-dir", join(VSCODE_DATA, "data"));
160
- args.push("--extensions-dir", VSCODE_EXTENSIONS_LOCAL);
220
+ args.push("--user-data-dir", join(dataDir, "data"));
221
+ args.push("--extensions-dir", join(dataDir, "extensions"));
161
222
  }
162
- args.push(WORK_DIR);
223
+ args.push(...workDirs);
163
224
  return {
164
225
  bin: VSCODE_APP,
165
226
  args
@@ -171,7 +232,7 @@ function buildCommand() {
171
232
  };
172
233
  case "claude": return {
173
234
  bin: "claude",
174
- args: [WORK_DIR]
235
+ args: [workDirs[0]]
175
236
  };
176
237
  case "exec": return {
177
238
  bin: execCmd[0],
@@ -179,18 +240,71 @@ function buildCommand() {
179
240
  };
180
241
  }
181
242
  }
182
- const cmd = buildCommand();
243
+ //#endregion
244
+ //#region src/index.ts
245
+ const __dirname = dirname(fileURLToPath(import.meta.url));
246
+ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
247
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
248
+ console.log(`bx ${VERSION}`);
249
+ process.exit(0);
250
+ }
251
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
252
+ console.log(`bx ${VERSION} — launch apps in a macOS sandbox
253
+
254
+ Usage:
255
+ bx [workdir...] VSCode (default)
256
+ bx code [workdir...] VSCode
257
+ bx term [workdir...] sandboxed login shell
258
+ bx claude [workdir...] Claude Code CLI
259
+ bx exec [workdir...] -- command [args...] arbitrary command
260
+
261
+ Options:
262
+ --verbose print the generated sandbox profile
263
+ --profile-sandbox use an isolated VSCode profile (code mode only)
264
+ -v, --version show version
265
+ -h, --help show this help
266
+
267
+ Configuration:
268
+ ~/.bxallow extra allowed directories (one per line)
269
+ ~/.bxignore extra blocked paths in $HOME (one per line)
270
+ <workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
271
+
272
+ https://github.com/holtwick/bx-mac`);
273
+ process.exit(0);
274
+ }
275
+ checkOwnSandbox();
276
+ checkVSCodeTerminal();
277
+ checkExternalSandbox();
278
+ const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
279
+ const HOME = process.env.HOME;
280
+ const WORK_DIRS = workArgs.map((a) => resolve(a));
281
+ if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
282
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
283
+ const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
284
+ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
285
+ if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
286
+ const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
287
+ const profilePath = join("/tmp", `bx-${process.pid}.sb`);
288
+ writeFileSync(profilePath, profile);
289
+ const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
290
+ console.error(`sandbox: ${mode} mode, working directory: ${dirLabel}`);
291
+ if (verbose) {
292
+ console.error("\n--- Generated sandbox profile ---");
293
+ console.error(profile);
294
+ console.error("--- End of profile ---\n");
295
+ }
296
+ const cmd = buildCommand(mode, WORK_DIRS, HOME, profileSandbox, execCmd);
183
297
  spawn("sandbox-exec", [
184
298
  "-f",
185
299
  profilePath,
186
300
  "-D",
187
301
  `HOME=${HOME}`,
188
302
  "-D",
189
- `WORK=${WORK_DIR}`,
303
+ `WORK=${WORK_DIRS[0]}`,
190
304
  cmd.bin,
191
305
  ...cmd.args
192
306
  ], {
193
- cwd: WORK_DIR,
307
+ cwd: WORK_DIRS[0],
194
308
  stdio: "inherit",
195
309
  env: {
196
310
  ...process.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "Launch apps in a macOS sandbox — only the project directory is accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,8 +11,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "rolldown -c",
14
- "release": "./scripts/release.sh",
15
- "prepublishOnly": "npm run build"
14
+ "prepublishOnly": "npm run build",
15
+ "post:release": "./scripts/release.sh"
16
16
  },
17
17
  "keywords": [
18
18
  "sandbox",
@@ -24,6 +24,7 @@
24
24
  "author": "Dirk Holtwick",
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
+ "@types/node": "^25.5.0",
27
28
  "rolldown": "^1.0.0-rc.12"
28
29
  },
29
30
  "engines": {