bx-mac 0.4.0 → 0.6.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ bx ~/work/my-project ~/work/shared-lib
28
28
  - ⚙️ Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
29
29
  - 🔍 Generates sandbox rules dynamically based on your actual `$HOME` contents
30
30
  - 📝 Supports `.bxignore` files (searched recursively) to hide secrets like `.env` files within a project
31
- - 📂 Supports `~/.bxallow` to grant access to shared utility directories
31
+ - 📂 Supports `rw:` and `ro:` prefixes in `~/.bxignore` to grant read-write or read-only access to extra directories
32
32
  - 🗂️ Supports multiple working directories in a single sandbox
33
33
 
34
34
  ## 🚫 What it doesn't do
@@ -100,43 +100,53 @@ bx --verbose ~/work/my-project
100
100
 
101
101
  ## 📝 Configuration
102
102
 
103
- bx uses three optional config files — one entry per line, `#` for comments. Project `.bxignore` files are discovered recursively.
104
-
105
- ### `~/.bxallow`
106
-
107
- Allow extra directories beyond the working directory. Paths relative to `$HOME`.
108
-
109
- ```gitignore
110
- # Shared shell scripts and utilities
111
- work/bin
112
- shared/libs
113
- ```
103
+ bx uses two optional config files — one entry per line, `#` for comments. Project `.bxignore` files are discovered recursively.
114
104
 
115
105
  ### `~/.bxignore`
116
106
 
117
- 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).
118
108
 
119
109
  ```gitignore
110
+ # Block additional sensitive paths (no prefix = deny)
120
111
  .aws
121
112
  .azure
122
113
  .kube
123
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
124
123
  ```
125
124
 
126
- These are blocked **in addition** to the built-in protected list:
125
+ Deny rules are applied **in addition** to the built-in protected list:
127
126
 
128
127
  > 🔒 `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
129
128
 
130
129
  ### `<project>/.bxignore`
131
130
 
132
- 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.
131
+ Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
132
+
133
+ | Pattern | Matches | Why |
134
+ |---|---|---|
135
+ | `.env` | `.env` at any depth | No `/` → recursive |
136
+ | `.env.*` | `.env.local`, `sub/.env.production` | No `/` → recursive |
137
+ | `*.pem` | `key.pem`, `sub/deep/cert.pem` | No `/` → recursive |
138
+ | `secrets/` | `secrets/` at any depth | Trailing `/` is a dir marker, not a path separator |
139
+ | `/.env` | Only `<workdir>/.env` | Leading `/` → anchored to root |
140
+ | `config/secrets` | Only `<workdir>/config/secrets` | Contains `/` → relative to workdir |
141
+
142
+ 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.
133
143
 
134
144
  ```gitignore
135
145
  .env
136
146
  .env.*
137
147
  secrets/
138
- **/*.pem
139
- **/*.key
148
+ *.pem
149
+ *.key
140
150
  ```
141
151
 
142
152
  For example, a monorepo might have:
@@ -155,10 +165,11 @@ bx generates a macOS sandbox profile at launch time:
155
165
 
156
166
  1. **Scan** `$HOME` for non-hidden directories
157
167
  2. **Block** each one individually with `(deny file* (subpath ...))`
158
- 3. **Skip** all working directories, `~/Library`, dotfiles, and `~/.bxallow` paths
168
+ 3. **Skip** all working directories, `~/Library`, dotfiles, and `rw:`/`ro:` paths from `~/.bxignore`
159
169
  4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules)
160
- 5. **Append** deny rules for protected dotdirs, `~/.bxignore`, and `.bxignore` files found recursively in each working directory
161
- 6. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
170
+ 5. **Append** deny rules for protected dotdirs, plain entries in `~/.bxignore`, and `.bxignore` files found recursively in each working directory
171
+ 6. **Apply** `(deny file-write*)` rules for `ro:` directories (read allowed, write blocked)
172
+ 7. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
162
173
 
163
174
  ### Why not a simple deny-all + allow?
164
175
 
package/dist/bx-native ADDED
Binary file
package/dist/bx.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const __VERSION__ = "0.4.0";
2
+ const __VERSION__ = "0.6.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";
@@ -121,10 +121,22 @@ function parseLines(filePath) {
121
121
  return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
122
122
  }
123
123
  /**
124
+ * Convert a .bxignore line to a glob pattern following .gitignore semantics:
125
+ * - Leading "/" anchors to the base dir (stripped before globbing)
126
+ * - Patterns without "/" (except trailing) match recursively via ** / prefix
127
+ * - Patterns with "/" (non-leading, non-trailing) are relative to baseDir
128
+ * - Trailing "/" marks directories only and doesn't count as path separator
129
+ */
130
+ function toGlobPattern(line) {
131
+ if (line.startsWith("/")) return line.slice(1);
132
+ if ((line.endsWith("/") ? line.slice(0, -1) : line).includes("/")) return line;
133
+ return `**/${line}`;
134
+ }
135
+ /**
124
136
  * Apply a single .bxignore file: resolve glob patterns relative to baseDir.
125
137
  */
126
138
  function applyIgnoreFile(filePath, baseDir, ignored) {
127
- for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
139
+ for (const line of parseLines(filePath)) for (const match of globSync(toGlobPattern(line), { cwd: baseDir })) ignored.push(resolve(baseDir, match));
128
140
  }
129
141
  /**
130
142
  * Recursively find and apply .bxignore files in a directory tree.
@@ -147,15 +159,39 @@ function collectIgnoreFilesRecursive(dir, ignored) {
147
159
  }
148
160
  }
149
161
  /**
150
- * Parse ~/.bxallow and return a set of all allowed directories.
162
+ * Parse ~/.bxignore for RW:/RO: prefixed lines and return allowed directories.
163
+ * Lines without prefix are ignored here (handled by collectIgnoredPaths).
164
+ * Also checks for deprecated ~/.bxallow and migrates its entries.
151
165
  */
152
- function parseAllowedDirs(home, workDirs) {
166
+ function parseHomeConfig(home, workDirs) {
153
167
  const allowed = new Set(workDirs);
154
- for (const line of parseLines(join(home, ".bxallow"))) {
155
- const absolute = resolve(home, line);
156
- if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
168
+ const readOnly = /* @__PURE__ */ new Set();
169
+ const bxallowPath = join(home, ".bxallow");
170
+ if (existsSync(bxallowPath)) {
171
+ console.error("sandbox: WARNING — ~/.bxallow is deprecated. Move entries to ~/.bxignore with RW: prefix.");
172
+ for (const line of parseLines(bxallowPath)) {
173
+ const absolute = resolve(home, line);
174
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
175
+ }
157
176
  }
158
- return allowed;
177
+ for (const line of parseLines(join(home, ".bxignore"))) {
178
+ let prefix = "";
179
+ let path = line;
180
+ const match = line.match(/^(RW|RO):(.+)$/i);
181
+ if (match) {
182
+ prefix = match[1].toUpperCase();
183
+ path = match[2].trim();
184
+ }
185
+ if (!prefix) continue;
186
+ const absolute = resolve(home, path);
187
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) continue;
188
+ if (prefix === "RW") allowed.add(absolute);
189
+ else readOnly.add(absolute);
190
+ }
191
+ return {
192
+ allowed,
193
+ readOnly
194
+ };
159
195
  }
160
196
  /**
161
197
  * Recursively collect directories to block under parentDir.
@@ -180,22 +216,27 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
180
216
  }
181
217
  /**
182
218
  * Collect paths to deny from .bxignore files and built-in protected dotdirs.
183
- * Searches ~/.bxignore and recursively through all workdirs.
219
+ * Searches ~/.bxignore (skipping RW:/RO: lines) and recursively through all workdirs.
184
220
  */
185
221
  function collectIgnoredPaths(home, workDirs) {
186
222
  const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
187
- applyIgnoreFile(join(home, ".bxignore"), home, ignored);
223
+ const globalIgnore = join(home, ".bxignore");
224
+ if (existsSync(globalIgnore)) {
225
+ const denyLines = parseLines(globalIgnore).filter((l) => !l.match(/^(RW|RO):/i));
226
+ for (const line of denyLines) for (const match of globSync(toGlobPattern(line), { cwd: home })) ignored.push(resolve(home, match));
227
+ }
188
228
  for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
189
229
  return ignored;
190
230
  }
191
231
  /**
192
232
  * Generate the SBPL sandbox profile string.
193
233
  */
194
- function generateProfile(workDirs, blockedDirs, ignoredPaths) {
234
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = []) {
195
235
  const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
196
236
  const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
197
237
  return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
198
238
  }).join("\n")}\n)\n` : "";
239
+ const readOnlyRules = readOnlyDirs.length > 0 ? `\n; Read-only directories\n(deny file-write*\n${readOnlyDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}\n)\n` : "";
199
240
  return `; Auto-generated sandbox profile
200
241
  ; Working directories: ${workDirs.join(", ")}
201
242
 
@@ -206,7 +247,7 @@ function generateProfile(workDirs, blockedDirs, ignoredPaths) {
206
247
  (deny file*
207
248
  ${denyRules}
208
249
  )
209
- ${ignoredRules}
250
+ ${ignoredRules}${readOnlyRules}
210
251
  `;
211
252
  }
212
253
  //#endregion
@@ -282,9 +323,11 @@ Options:
282
323
  -h, --help show this help
283
324
 
284
325
  Configuration:
285
- ~/.bxallow extra allowed directories (one per line)
286
- ~/.bxignore extra blocked paths in $HOME (one per line)
287
- <workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
326
+ ~/.bxignore sandbox rules (one per line):
327
+ path block access (deny)
328
+ rw:path allow read-write access
329
+ ro:path allow read-only access
330
+ <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
288
331
 
289
332
  https://github.com/holtwick/bx-mac`);
290
333
  process.exit(0);
@@ -297,11 +340,13 @@ const HOME = process.env.HOME;
297
340
  const WORK_DIRS = workArgs.map((a) => resolve(a));
298
341
  checkWorkDirs(WORK_DIRS, HOME);
299
342
  if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
300
- const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
343
+ const { allowed, readOnly } = parseHomeConfig(HOME, WORK_DIRS);
344
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
301
345
  const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
302
346
  const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
303
347
  if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
304
- const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
348
+ if (readOnly.size > 0) console.error(`sandbox: ${readOnly.size} read-only director${readOnly.size === 1 ? "y" : "ies"}`);
349
+ const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths, [...readOnly]);
305
350
  const profilePath = join("/tmp", `bx-${process.pid}.sb`);
306
351
  writeFileSync(profilePath, profile);
307
352
  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.4.0",
3
+ "version": "0.6.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,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "rolldown -c",
14
+ "build:native": "bun build src/index.ts --compile --outfile dist/bx-native --define \"__VERSION__=\\\"$(node -p \"require('./package.json').version\")\\\"\"",
15
+ "sign": "./scripts/sign.sh",
14
16
  "test": "vitest run",
15
17
  "prepublishOnly": "npm run build",
16
18
  "post:release": "./scripts/release.sh"