bx-mac 0.3.0 → 0.5.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 +45 -25
  2. package/dist/bx.js +67 -16
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -14,6 +14,12 @@ bx ~/work/my-project
14
14
 
15
15
  That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing else.
16
16
 
17
+ Need multiple directories? No problem:
18
+
19
+ ```bash
20
+ bx ~/work/my-project ~/work/shared-lib
21
+ ```
22
+
17
23
  ## ✅ What it does
18
24
 
19
25
  - 🔒 Blocks `~/Documents`, `~/Desktop`, `~/Downloads`, and all other personal folders
@@ -21,8 +27,9 @@ That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing
21
27
  - 🛡️ Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo`
22
28
  - ⚙️ Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
23
29
  - 🔍 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
30
+ - 📝 Supports `.bxignore` files (searched recursively) to hide secrets like `.env` files within a project
31
+ - 📂 Supports `rw:` and `ro:` prefixes in `~/.bxignore` to grant read-write or read-only access to extra directories
32
+ - 🗂️ Supports multiple working directories in a single sandbox
26
33
 
27
34
  ## 🚫 What it doesn't do
28
35
 
@@ -54,13 +61,13 @@ pnpm link -g
54
61
 
55
62
  | Command | What it launches |
56
63
  |---|---|
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 |
64
+ | `bx [workdir...]` | 🖥️ VSCode (default) |
65
+ | `bx code [workdir...]` | 🖥️ VSCode (explicit) |
66
+ | `bx term [workdir...]` | 💻 Sandboxed login shell (`$SHELL -l`) |
67
+ | `bx claude [workdir...]` | 🤖 Claude Code CLI |
68
+ | `bx exec [workdir...] -- cmd` | ⚡ Any command you want |
62
69
 
63
- If no directory is given, the current directory is used.
70
+ If no directory is given, the current directory is used. All modes accept multiple directories.
64
71
 
65
72
  ### Examples
66
73
 
@@ -68,6 +75,9 @@ If no directory is given, the current directory is used.
68
75
  # 🖥️ VSCode with sandbox protection
69
76
  bx ~/work/my-project
70
77
 
78
+ # 📂 Multiple working directories
79
+ bx ~/work/my-project ~/work/shared-lib
80
+
71
81
  # 💻 Work on a project in a sandboxed terminal
72
82
  bx term ~/work/my-project
73
83
 
@@ -90,36 +100,35 @@ bx --verbose ~/work/my-project
90
100
 
91
101
  ## 📝 Configuration
92
102
 
93
- bx uses three optional config files — one entry per line, `#` for comments.
94
-
95
- ### `~/.bxallow`
96
-
97
- Allow extra directories beyond the working directory. Paths relative to `$HOME`.
98
-
99
- ```gitignore
100
- # Shared shell scripts and utilities
101
- work/bin
102
- shared/libs
103
- ```
103
+ bx uses two optional config files — one entry per line, `#` for comments. Project `.bxignore` files are discovered recursively.
104
104
 
105
105
  ### `~/.bxignore`
106
106
 
107
- Block additional dotdirs or files in your home. Paths relative to `$HOME`.
107
+ Unified sandbox rules for your home directory. Paths relative to `$HOME`. Each line is either a deny rule (no prefix) or an access grant (`rw:` / `ro:` prefix, case-insensitive).
108
108
 
109
109
  ```gitignore
110
+ # Block additional sensitive paths (no prefix = deny)
110
111
  .aws
111
112
  .azure
112
113
  .kube
113
114
  .config/gcloud
115
+
116
+ # Allow read-write access to extra directories
117
+ rw:work/bin
118
+ rw:shared/libs
119
+
120
+ # Allow read-only access (can read but not modify)
121
+ ro:reference/docs
122
+ ro:shared/toolchain
114
123
  ```
115
124
 
116
- These are blocked **in addition** to the built-in protected list:
125
+ Deny rules are applied **in addition** to the built-in protected list:
117
126
 
118
127
  > 🔒 `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
119
128
 
120
129
  ### `<project>/.bxignore`
121
130
 
122
- Block paths within the working directory. Supports glob patterns.
131
+ Block paths within the working directory. Supports glob patterns. bx searches for `.bxignore` files **recursively** through the entire project tree (skipping `.`-prefixed dirs and `node_modules`), so you can place them in subdirectories to hide secrets close to where they live.
123
132
 
124
133
  ```gitignore
125
134
  .env
@@ -129,16 +138,27 @@ secrets/
129
138
  **/*.key
130
139
  ```
131
140
 
141
+ For example, a monorepo might have:
142
+
143
+ ```text
144
+ my-project/.bxignore # top-level rules
145
+ my-project/services/api/.bxignore # API-specific secrets
146
+ my-project/deploy/.bxignore # deployment credentials
147
+ ```
148
+
149
+ Each `.bxignore` resolves its patterns relative to its own directory.
150
+
132
151
  ## 🔧 How it works
133
152
 
134
153
  bx generates a macOS sandbox profile at launch time:
135
154
 
136
155
  1. **Scan** `$HOME` for non-hidden directories
137
156
  2. **Block** each one individually with `(deny file* (subpath ...))`
138
- 3. **Skip** the working directory, `~/Library`, dotfiles, and `~/.bxallow` paths
157
+ 3. **Skip** all working directories, `~/Library`, dotfiles, and `rw:`/`ro:` paths from `~/.bxignore`
139
158
  4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules)
140
- 5. **Append** deny rules for protected dotdirs and `.bxignore` entries
141
- 6. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
159
+ 5. **Append** deny rules for protected dotdirs, plain entries in `~/.bxignore`, and `.bxignore` files found recursively in each working directory
160
+ 6. **Apply** `(deny file-write*)` rules for `ro:` directories (read allowed, write blocked)
161
+ 7. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
142
162
 
143
163
  ### Why not a simple deny-all + allow?
144
164
 
package/dist/bx.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const __VERSION__ = "0.3.0";
2
+ const __VERSION__ = "0.5.0";
3
3
  import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { spawn } from "node:child_process";
@@ -27,6 +27,23 @@ function checkVSCodeTerminal() {
27
27
  }
28
28
  }
29
29
  /**
30
+ * Abort if any workdir IS $HOME or is not inside $HOME.
31
+ */
32
+ function checkWorkDirs(workDirs, home) {
33
+ for (const dir of workDirs) {
34
+ if (dir === home) {
35
+ console.error("sandbox: ERROR — working directory cannot be $HOME itself.");
36
+ console.error("sandbox: Sandboxing your entire home directory is not supported. Aborting.");
37
+ process.exit(1);
38
+ }
39
+ if (!dir.startsWith(home + "/")) {
40
+ console.error(`sandbox: ERROR — working directory is outside $HOME: ${dir}`);
41
+ console.error("sandbox: Only directories inside $HOME are supported. Aborting.");
42
+ process.exit(1);
43
+ }
44
+ }
45
+ }
46
+ /**
30
47
  * Detect if we're inside an unknown sandbox by probing well-known
31
48
  * directories that exist on every Mac but would be blocked.
32
49
  */
@@ -130,15 +147,39 @@ function collectIgnoreFilesRecursive(dir, ignored) {
130
147
  }
131
148
  }
132
149
  /**
133
- * Parse ~/.bxallow and return a set of all allowed directories.
150
+ * Parse ~/.bxignore for RW:/RO: prefixed lines and return allowed directories.
151
+ * Lines without prefix are ignored here (handled by collectIgnoredPaths).
152
+ * Also checks for deprecated ~/.bxallow and migrates its entries.
134
153
  */
135
- function parseAllowedDirs(home, workDirs) {
154
+ function parseHomeConfig(home, workDirs) {
136
155
  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);
156
+ const readOnly = /* @__PURE__ */ new Set();
157
+ const bxallowPath = join(home, ".bxallow");
158
+ if (existsSync(bxallowPath)) {
159
+ console.error("sandbox: WARNING — ~/.bxallow is deprecated. Move entries to ~/.bxignore with RW: prefix.");
160
+ for (const line of parseLines(bxallowPath)) {
161
+ const absolute = resolve(home, line);
162
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
163
+ }
164
+ }
165
+ for (const line of parseLines(join(home, ".bxignore"))) {
166
+ let prefix = "";
167
+ let path = line;
168
+ const match = line.match(/^(RW|RO):(.+)$/i);
169
+ if (match) {
170
+ prefix = match[1].toUpperCase();
171
+ path = match[2].trim();
172
+ }
173
+ if (!prefix) continue;
174
+ const absolute = resolve(home, path);
175
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) continue;
176
+ if (prefix === "RW") allowed.add(absolute);
177
+ else readOnly.add(absolute);
140
178
  }
141
- return allowed;
179
+ return {
180
+ allowed,
181
+ readOnly
182
+ };
142
183
  }
143
184
  /**
144
185
  * Recursively collect directories to block under parentDir.
@@ -163,22 +204,27 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
163
204
  }
164
205
  /**
165
206
  * Collect paths to deny from .bxignore files and built-in protected dotdirs.
166
- * Searches ~/.bxignore and recursively through all workdirs.
207
+ * Searches ~/.bxignore (skipping RW:/RO: lines) and recursively through all workdirs.
167
208
  */
168
209
  function collectIgnoredPaths(home, workDirs) {
169
210
  const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
170
- applyIgnoreFile(join(home, ".bxignore"), home, ignored);
211
+ const globalIgnore = join(home, ".bxignore");
212
+ if (existsSync(globalIgnore)) {
213
+ const denyLines = parseLines(globalIgnore).filter((l) => !l.match(/^(RW|RO):/i));
214
+ for (const line of denyLines) for (const match of globSync(line, { cwd: home })) ignored.push(resolve(home, match));
215
+ }
171
216
  for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
172
217
  return ignored;
173
218
  }
174
219
  /**
175
220
  * Generate the SBPL sandbox profile string.
176
221
  */
177
- function generateProfile(workDirs, blockedDirs, ignoredPaths) {
222
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = []) {
178
223
  const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
179
224
  const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
180
225
  return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
181
226
  }).join("\n")}\n)\n` : "";
227
+ const readOnlyRules = readOnlyDirs.length > 0 ? `\n; Read-only directories\n(deny file-write*\n${readOnlyDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}\n)\n` : "";
182
228
  return `; Auto-generated sandbox profile
183
229
  ; Working directories: ${workDirs.join(", ")}
184
230
 
@@ -189,7 +235,7 @@ function generateProfile(workDirs, blockedDirs, ignoredPaths) {
189
235
  (deny file*
190
236
  ${denyRules}
191
237
  )
192
- ${ignoredRules}
238
+ ${ignoredRules}${readOnlyRules}
193
239
  `;
194
240
  }
195
241
  //#endregion
@@ -232,7 +278,7 @@ function buildCommand(mode, workDirs, home, profileSandbox, execCmd) {
232
278
  };
233
279
  case "claude": return {
234
280
  bin: "claude",
235
- args: [workDirs[0]]
281
+ args: []
236
282
  };
237
283
  case "exec": return {
238
284
  bin: execCmd[0],
@@ -265,8 +311,10 @@ Options:
265
311
  -h, --help show this help
266
312
 
267
313
  Configuration:
268
- ~/.bxallow extra allowed directories (one per line)
269
- ~/.bxignore extra blocked paths in $HOME (one per line)
314
+ ~/.bxignore sandbox rules (one per line):
315
+ path block access (deny)
316
+ rw:path allow read-write access
317
+ ro:path allow read-only access
270
318
  <workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
271
319
 
272
320
  https://github.com/holtwick/bx-mac`);
@@ -278,12 +326,15 @@ checkExternalSandbox();
278
326
  const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
279
327
  const HOME = process.env.HOME;
280
328
  const WORK_DIRS = workArgs.map((a) => resolve(a));
329
+ checkWorkDirs(WORK_DIRS, HOME);
281
330
  if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
282
- const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
331
+ const { allowed, readOnly } = parseHomeConfig(HOME, WORK_DIRS);
332
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
283
333
  const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
284
334
  const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
285
335
  if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
286
- const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
336
+ if (readOnly.size > 0) console.error(`sandbox: ${readOnly.size} read-only director${readOnly.size === 1 ? "y" : "ies"}`);
337
+ const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths, [...readOnly]);
287
338
  const profilePath = join("/tmp", `bx-${process.pid}.sb`);
288
339
  writeFileSync(profilePath, profile);
289
340
  const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Launch apps in a macOS sandbox — only the project directory is accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "rolldown -c",
14
+ "test": "vitest run",
14
15
  "prepublishOnly": "npm run build",
15
16
  "post:release": "./scripts/release.sh"
16
17
  },
@@ -25,7 +26,8 @@
25
26
  "license": "MIT",
26
27
  "devDependencies": {
27
28
  "@types/node": "^25.5.0",
28
- "rolldown": "^1.0.0-rc.12"
29
+ "rolldown": "^1.0.0-rc.12",
30
+ "vitest": "^4.1.2"
29
31
  },
30
32
  "engines": {
31
33
  "node": ">=22"