bx-mac 1.4.0 → 1.6.1

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 +39 -5
  2. package/dist/bx.js +107 -18
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -166,7 +166,6 @@ App definitions in TOML format. Each `[<name>]` section becomes a CLI mode — u
166
166
  [cursor]
167
167
  bundle = "com.todesktop.230313mzl4w4u92"
168
168
  binary = "Contents/MacOS/Cursor"
169
- args = ["--no-sandbox"]
170
169
 
171
170
  # Add Zed (explicit path, no discovery)
172
171
  [zed]
@@ -177,6 +176,8 @@ path = "/Applications/Zed.app/Contents/MacOS/zed"
177
176
  path = "/usr/local/bin/code"
178
177
  ```
179
178
 
179
+ **Electron apps:** bx automatically detects Electron-based apps (by checking for `Electron Framework.framework` inside the `.app` bundle) and adds `--no-sandbox` to disable Chromium's internal sandbox, which conflicts with `sandbox-exec`. No manual `args = ["--no-sandbox"]` needed.
180
+
180
181
  | Field | Description |
181
182
  | --- | --- |
182
183
  | `mode` | Inherit from another app (e.g. `"code"`, `"cursor"`) — only `paths` / overrides needed |
@@ -234,23 +235,32 @@ Unified sandbox rules for your home directory. Paths relative to `$HOME`. Each l
234
235
  .kube
235
236
  .config/gcloud
236
237
 
237
- # Allow read-write access to extra directories
238
+ # Allow read-write access to extra paths
238
239
  rw:work/bin
239
240
  rw:shared/libs
241
+ rw:~/projects/* # globs supported, ~ is expanded
240
242
 
241
243
  # Allow read-only access (can read but not modify)
242
244
  ro:reference/docs
243
245
  ro:shared/toolchain
246
+ ro:.npmrc # files work too; overrides built-in block
244
247
  ```
245
248
 
246
- `rw:` and `ro:` entries also **override** the built-in protected lists - e.g. `ro:.npmrc` makes the otherwise-blocked `~/.npmrc` readable, `rw:.aws` opens the AWS credentials directory. Files (not just directories) are accepted as targets. Use this with care - you are explicitly weakening the default protection.
249
+ Plain deny rules have two scopes:
250
+
251
+ - **At `$HOME` top level only** (no recursive `**` walk). `secrets/` matches `~/secrets`, not `~/nested/secrets`; `.config/gcloud` matches as a literal.
252
+ - **Recursively inside each workdir** - same semantics as a project-level `.bxignore`, so `secrets/` and `*.pem` apply across every project you open. The recursive scan skips `node_modules`, `.git`, caches, `DerivedData`, `Pods`, and similar subtrees for speed.
253
+
254
+ Deny rules are applied **in addition** to the built-in protected lists. `rw:` and `ro:` entries however **override** them - `ro:.npmrc` exposes the otherwise-blocked `~/.npmrc` read-only, `rw:.aws` opens the AWS credentials directory completely. Files (not just directories) are accepted, `~/...` is expanded, and globs (`*`, `**`) are matched against `$HOME`. Use override entries deliberately - you are weakening the default protection. `bx` prints a warning on stderr when an override exposes a built-in protected path.
247
255
 
248
- Deny rules are applied **in addition** to the built-in protected lists:
256
+ Built-in protected lists:
249
257
 
250
258
  > 🔒 **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
251
259
  >
252
260
  > 🏛️ **Library (opinionated):** `Accounts` `Calendars` `Contacts` `Cookies` `Finance` `Mail` `Messages` `Mobile Documents` `Photos` `Safari` and [others (see full list)](src/profile.ts) — plus containers of password managers & finance apps
253
261
 
262
+ **Limitation:** Overrides only work on whole protected paths. `rw:.aws/profile.json` does not selectively unblock a file inside `~/.aws` - the parent deny still wins (Apple SBPL: deny beats allow). Use `rw:.aws` to open the entire directory.
263
+
254
264
  ### `<project>/.bxignore`
255
265
 
256
266
  Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
@@ -284,6 +294,13 @@ my-project/deploy/.bxignore # deployment credentials
284
294
 
285
295
  Each `.bxignore` resolves its patterns relative to its own directory.
286
296
 
297
+ Project `.bxignore` also accepts `ro:` entries to make paths within the workdir read-only (write-protect generated files, vendored libraries, etc.). `rw:` is silently ignored here - the workdir is already read-write by default.
298
+
299
+ ```gitignore
300
+ ro:vendor/
301
+ ro:generated/schema.ts
302
+ ```
303
+
287
304
  ### Self-protecting directories
288
305
 
289
306
  You can make any directory protect itself — no global configuration needed. There are two ways:
@@ -345,7 +362,8 @@ bx detects and prevents problematic scenarios:
345
362
  - **🔄 Sandbox nesting:** If `CODEBOX_SANDBOX=1` is set (auto-propagated), bx refuses to start — nested sandboxes cause silent failures.
346
363
  - **🔍 Unknown sandbox:** On startup, bx probes `~/Documents`, `~/Desktop`, `~/Downloads`. If any return `EPERM`, another sandbox is active — bx aborts.
347
364
  - **⚠️ VSCode terminal:** If `VSCODE_PID` is set, bx warns that it will launch a *new* instance, not sandbox the current one.
348
- - **🧩 App already sandboxed:** For GUI app modes, bx inspects app entitlements (best effort) and warns if Apple App Sandbox is enabled, since nested sandboxing can cause startup/access issues.
365
+ - **🧩 App already sandboxed:** For GUI app modes, bx inspects app entitlements (best effort) and warns if Apple App Sandbox is enabled, since bx's `sandbox-exec` wrapper may not apply correctly when the app is already sandboxed by macOS.
366
+ - **⚡ Electron auto-detection:** bx detects Electron-based apps (by checking for `Electron Framework.framework` in the `.app` bundle) and automatically adds `--no-sandbox` to disable Chromium's internal sandbox, which conflicts with `sandbox-exec`. This works for VSCode, Cursor, Windsurf, and any other Electron app.
349
367
  - **🔁 App already running:** If the target app is already running, bx warns that the new workspace would open in the existing (unsandboxed) instance and asks for confirmation. This is important because Electron apps like VSCode, Cursor, etc. always reuse the running process — `sandbox-exec` has no effect on the already-running instance.
350
368
 
351
369
  ### Single-instance apps
@@ -378,6 +396,22 @@ ls ~/work/other-project/ # ❌ Operation not permitted
378
396
  cat ./src/index.ts # ✅ Works!
379
397
  ```
380
398
 
399
+ **Drag and drop from `~/Downloads` (or other blocked dirs)** — if dragging files from Finder into a sandboxed app fails silently, the source folder is blocked. Grant read-only access by adding it to `~/.bxignore`:
400
+
401
+ ```gitignore
402
+ ro:Downloads
403
+ ```
404
+
405
+ The app can then read (and copy) the dragged file, but not modify the original. Same trick works for `ro:Desktop`, `ro:Documents`, etc.
406
+
407
+ **`npm install` fails with auth or registry errors** — `.npmrc` is blocked by default (may contain tokens). If your install needs the registry config but not write access, expose it read-only:
408
+
409
+ ```gitignore
410
+ ro:.npmrc
411
+ ```
412
+
413
+ Same pattern applies to other tools whose config dotfiles are on the protected list (`.pypirc`, ...).
414
+
381
415
  ## ⚠️ Known limitations
382
416
 
383
417
  - **⚠️ Sandbox profile is static:** The sandbox rules are generated **once at launch** by scanning the current state of `$HOME`. Directories or files created **after** the sandbox starts are **not protected** — for example, if a tool creates `~/new-project/` while the sandbox is running, that directory will be fully accessible. Similarly, project-level `.bxignore` patterns only match files that exist at launch time; files matching a blocked pattern (e.g. `.env`) that are created later will **not** be denied. Re-run `bx` to pick up changes.
package/dist/bx.js CHANGED
@@ -1045,6 +1045,50 @@ function toGlobPattern(line) {
1045
1045
  function resolveGlobMatches(pattern, baseDir) {
1046
1046
  return globSync(toGlobPattern(pattern), { cwd: baseDir }).map((match) => resolve(baseDir, match));
1047
1047
  }
1048
+ const SCAN_EXCLUDE_NAMES = new Set([
1049
+ "Library",
1050
+ "Applications",
1051
+ "node_modules",
1052
+ ".git",
1053
+ ".Trash",
1054
+ ".cache",
1055
+ ".npm",
1056
+ ".pnpm-store",
1057
+ ".yarn",
1058
+ ".cargo",
1059
+ ".rustup",
1060
+ ".gradle",
1061
+ ".gem",
1062
+ ".m2",
1063
+ ".nvm",
1064
+ ".bun",
1065
+ ".deno",
1066
+ "DerivedData",
1067
+ "Pods"
1068
+ ]);
1069
+ function scanExcludeFilter(entry) {
1070
+ const name = typeof entry === "string" ? entry.split("/").pop() ?? "" : entry?.name ?? "";
1071
+ return SCAN_EXCLUDE_NAMES.has(name);
1072
+ }
1073
+ function resolveGlobMatchesBatch(patterns, baseDir) {
1074
+ if (patterns.length === 0) return [];
1075
+ return globSync(patterns.map(toGlobPattern), {
1076
+ cwd: baseDir,
1077
+ exclude: scanExcludeFilter
1078
+ }).map((match) => resolve(baseDir, match));
1079
+ }
1080
+ /**
1081
+ * Resolve patterns against `baseDir` at top level only (no recursive `**`
1082
+ * expansion). Trailing slashes are stripped; leading slashes are stripped
1083
+ * (already anchored). Used for `~/.bxignore` in $HOME to keep the lookup
1084
+ * cheap while still matching literal/glob paths at the home root.
1085
+ */
1086
+ function resolveTopLevelMatches(patterns, baseDir) {
1087
+ if (patterns.length === 0) return [];
1088
+ const cleaned = patterns.map((p) => p.startsWith("/") ? p.slice(1) : p).map((p) => p.endsWith("/") ? p.slice(0, -1) : p).filter((p) => p.length > 0);
1089
+ if (cleaned.length === 0) return [];
1090
+ return globSync(cleaned, { cwd: baseDir }).map((m) => resolve(baseDir, m));
1091
+ }
1048
1092
  /**
1049
1093
  * A directory is self-protected if it contains a `.bxprotect` file
1050
1094
  * or a `.bxignore` with a bare `/` entry. Self-protected directories
@@ -1055,19 +1099,27 @@ function isSelfProtected(dir) {
1055
1099
  if (existsSync(join(dir, ".bxprotect"))) return true;
1056
1100
  return parseLines(join(dir, ".bxignore")).some((l) => l === "/" || l === ".");
1057
1101
  }
1058
- function applyIgnoreFile(filePath, baseDir, ignored) {
1102
+ function applyIgnoreFile(filePath, baseDir, ignored, readOnly) {
1059
1103
  for (const line of parseLines(filePath)) {
1060
1104
  if (line === "/" || line === ".") continue;
1105
+ const accessMatch = line.match(ACCESS_PREFIX_RE);
1106
+ if (accessMatch) {
1107
+ if (!readOnly) continue;
1108
+ const [, prefix, rawPath] = accessMatch;
1109
+ if (prefix.toUpperCase() !== "RO") continue;
1110
+ for (const m of resolveGlobMatches(rawPath.trim(), baseDir)) readOnly.add(realpathSafe(m));
1111
+ continue;
1112
+ }
1061
1113
  ignored.push(...resolveGlobMatches(line, baseDir));
1062
1114
  }
1063
1115
  }
1064
- function collectIgnoreFilesRecursive(dir, ignored) {
1116
+ function collectIgnoreFilesRecursive(dir, ignored, readOnly) {
1065
1117
  if (isSelfProtected(dir)) {
1066
1118
  ignored.push(dir);
1067
1119
  return;
1068
1120
  }
1069
1121
  const ignoreFile = join(dir, ".bxignore");
1070
- if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
1122
+ if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored, readOnly);
1071
1123
  let entries;
1072
1124
  try {
1073
1125
  entries = readdirSync(dir);
@@ -1078,11 +1130,29 @@ function collectIgnoreFilesRecursive(dir, ignored) {
1078
1130
  if (name.startsWith(".") || name === "node_modules") continue;
1079
1131
  const fullPath = join(dir, name);
1080
1132
  try {
1081
- if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
1133
+ if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored, readOnly);
1082
1134
  } catch {}
1083
1135
  }
1084
1136
  }
1085
1137
  const ACCESS_PREFIX_RE = /^(RW|RO):(.+)$/i;
1138
+ function expandHomePath(home, raw) {
1139
+ const trimmed = raw.trim();
1140
+ if (trimmed === "~") return home;
1141
+ if (trimmed.startsWith("~/")) return join(home, trimmed.slice(2));
1142
+ return resolve(home, trimmed);
1143
+ }
1144
+ function realpathSafe(p) {
1145
+ try {
1146
+ return realpathSync(p);
1147
+ } catch {
1148
+ return p;
1149
+ }
1150
+ }
1151
+ function resolveAccessTargets(home, raw) {
1152
+ const expanded = expandHomePath(home, raw);
1153
+ if (existsSync(expanded)) return [realpathSafe(expanded)];
1154
+ return globSync(raw.trim().replace(/^~\//, ""), { cwd: home }).map((m) => realpathSafe(join(home, m))).filter((p) => existsSync(p));
1155
+ }
1086
1156
  function parseHomeConfig(home, workDirs) {
1087
1157
  const allowed = new Set(workDirs);
1088
1158
  const readOnly = /* @__PURE__ */ new Set();
@@ -1090,10 +1160,10 @@ function parseHomeConfig(home, workDirs) {
1090
1160
  const match = line.match(ACCESS_PREFIX_RE);
1091
1161
  if (!match) continue;
1092
1162
  const [, prefix, rawPath] = match;
1093
- const absolute = resolve(home, rawPath.trim());
1094
- if (!existsSync(absolute)) continue;
1095
- if (prefix.toUpperCase() === "RW") allowed.add(absolute);
1096
- else readOnly.add(absolute);
1163
+ const targets = resolveAccessTargets(home, rawPath);
1164
+ if (targets.length === 0) continue;
1165
+ const target = prefix.toUpperCase() === "RW" ? allowed : readOnly;
1166
+ for (const t of targets) target.add(t);
1097
1167
  }
1098
1168
  return {
1099
1169
  allowed,
@@ -1143,22 +1213,26 @@ function collectProtectedContainers(home) {
1143
1213
  }
1144
1214
  return matched;
1145
1215
  }
1216
+ function isOverridden(path, overrides) {
1217
+ return overrides.has(path) || overrides.has(realpathSafe(path));
1218
+ }
1146
1219
  function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
1147
- return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !overrides.has(p));
1220
+ return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !isOverridden(p, overrides));
1148
1221
  }
1149
- function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set()) {
1222
+ function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set(), readOnly) {
1150
1223
  const ignored = [
1151
1224
  ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1152
1225
  ...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
1153
1226
  ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
1154
1227
  ...new Set(collectProtectedContainers(home))
1155
- ].filter((p) => !overrides.has(p));
1228
+ ].filter((p) => !isOverridden(p, overrides));
1156
1229
  const globalIgnore = join(home, ".bxignore");
1157
- if (existsSync(globalIgnore)) {
1158
- const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
1159
- for (const line of denyLines) for (const m of resolveGlobMatches(line, home)) if (!overrides.has(m)) ignored.push(m);
1230
+ const globalDenyLines = existsSync(globalIgnore) ? parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l)) : [];
1231
+ for (const m of resolveTopLevelMatches(globalDenyLines, home)) if (!isOverridden(m, overrides)) ignored.push(m);
1232
+ for (const workDir of workDirs) {
1233
+ for (const m of resolveGlobMatchesBatch(globalDenyLines, workDir)) if (!isOverridden(m, overrides)) ignored.push(m);
1234
+ collectIgnoreFilesRecursive(workDir, ignored, readOnly);
1160
1235
  }
1161
- for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
1162
1236
  return ignored;
1163
1237
  }
1164
1238
  function sbplEscape(path) {
@@ -1409,6 +1483,11 @@ function executableFromBundle(bundlePath, app) {
1409
1483
  if (app.binary) return join(bundlePath, app.binary);
1410
1484
  return join(bundlePath, "Contents", "MacOS", basename(bundlePath, ".app"));
1411
1485
  }
1486
+ function isElectronApp(resolvedPath) {
1487
+ const bundle = appBundleFromPath(resolvedPath);
1488
+ if (!bundle) return false;
1489
+ return existsSync(join(bundle, "Contents", "Frameworks", "Electron Framework.framework"));
1490
+ }
1412
1491
  const SANDBOX_KEY = "com.apple.security.app-sandbox";
1413
1492
  function hasAppSandboxEntitlement(entitlements) {
1414
1493
  if (new RegExp(`<key>\\s*${SANDBOX_KEY.replace(/\./g, "\\.")}\\s*</key>\\s*<true\\s*/>`, "i").test(entitlements)) return true;
@@ -1491,6 +1570,7 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1491
1570
  args.push("--extensions-dir", join(dataDir, "extensions"));
1492
1571
  }
1493
1572
  if (app.args) args.push(...app.args);
1573
+ if (!args.includes("--no-sandbox") && isElectronApp(resolvedPath)) args.push("--no-sandbox");
1494
1574
  if (appArgs.length > 0) args.push(...appArgs);
1495
1575
  args.push(...getPassPaths(app, workDirs, home));
1496
1576
  return {
@@ -1535,7 +1615,7 @@ function getNestedSandboxWarning(mode, apps) {
1535
1615
  "pipe",
1536
1616
  "pipe"
1537
1617
  ]
1538
- }))) return `⚠️ "${mode}" has Apple App Sandbox enabled — nested sandboxing may cause issues`;
1618
+ }))) return `"${mode}" has Apple App Sandbox enabled — bx sandbox may not apply correctly`;
1539
1619
  } catch {}
1540
1620
  return null;
1541
1621
  }
@@ -1699,7 +1779,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1699
1779
  }
1700
1780
  //#endregion
1701
1781
  //#region src/index.ts
1702
- const VERSION = "1.4.0";
1782
+ const VERSION = "1.6.1";
1703
1783
  const __dirname = dirname(fileURLToPath(import.meta.url));
1704
1784
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1705
1785
  console.log(`bx ${VERSION}`);
@@ -1749,8 +1829,9 @@ async function main() {
1749
1829
  if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
1750
1830
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1751
1831
  const allAccessible = new Set([...allowed, ...readOnly]);
1832
+ warnDangerousOverrides(HOME, allAccessible);
1752
1833
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
1753
- const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible);
1834
+ const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible, readOnly);
1754
1835
  const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
1755
1836
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1756
1837
  const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
@@ -1878,6 +1959,14 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1878
1959
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1879
1960
  console.error(fmt.detail(parts.join(" · ")));
1880
1961
  }
1962
+ function warnDangerousOverrides(home, accessible) {
1963
+ const hits = [
1964
+ ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1965
+ ...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
1966
+ ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d))
1967
+ ].filter((p) => accessible.has(p));
1968
+ for (const p of hits) console.error(fmt.detail(`warning: ~/.bxignore override exposes built-in protected path ${p}`));
1969
+ }
1881
1970
  function printLaunchDetails(cmd, cwd) {
1882
1971
  const quote = (a) => JSON.stringify(a);
1883
1972
  console.error(fmt.detail(`bin: ${cmd.bin}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "1.4.0",
3
+ "version": "1.6.1",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,10 +47,10 @@
47
47
  "darwin"
48
48
  ],
49
49
  "devDependencies": {
50
- "@types/node": "^25.5.2",
51
- "rolldown": "^1.0.0-rc.13",
50
+ "@types/node": "^25.6.0",
51
+ "rolldown": "^1.0.0-rc.16",
52
52
  "smol-toml": "^1.6.1",
53
- "vitest": "^4.1.2"
53
+ "vitest": "^4.1.4"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=22"