bx-mac 1.3.0 โ†’ 1.5.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 +21 -2
  2. package/dist/bx.js +107 -23
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -234,21 +234,32 @@ Unified sandbox rules for your home directory. Paths relative to `$HOME`. Each l
234
234
  .kube
235
235
  .config/gcloud
236
236
 
237
- # Allow read-write access to extra directories
237
+ # Allow read-write access to extra paths
238
238
  rw:work/bin
239
239
  rw:shared/libs
240
+ rw:~/projects/* # globs supported, ~ is expanded
240
241
 
241
242
  # Allow read-only access (can read but not modify)
242
243
  ro:reference/docs
243
244
  ro:shared/toolchain
245
+ ro:.npmrc # files work too; overrides built-in block
244
246
  ```
245
247
 
246
- Deny rules are applied **in addition** to the built-in protected lists:
248
+ Plain deny rules have two scopes:
249
+
250
+ - **At `$HOME` top level only** (no recursive `**` walk). `secrets/` matches `~/secrets`, not `~/nested/secrets`; `.config/gcloud` matches as a literal.
251
+ - **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.
252
+
253
+ 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.
254
+
255
+ Built-in protected lists:
247
256
 
248
257
  > ๐Ÿ”’ **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
249
258
  >
250
259
  > ๐Ÿ›๏ธ **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
251
260
 
261
+ **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.
262
+
252
263
  ### `<project>/.bxignore`
253
264
 
254
265
  Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
@@ -282,6 +293,13 @@ my-project/deploy/.bxignore # deployment credentials
282
293
 
283
294
  Each `.bxignore` resolves its patterns relative to its own directory.
284
295
 
296
+ 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.
297
+
298
+ ```gitignore
299
+ ro:vendor/
300
+ ro:generated/schema.ts
301
+ ```
302
+
285
303
  ### Self-protecting directories
286
304
 
287
305
  You can make any directory protect itself โ€” no global configuration needed. There are two ways:
@@ -397,6 +415,7 @@ These are great when available, but they only protect within their own tool. bx
397
415
  ## ๐Ÿ”— Alternatives
398
416
 
399
417
  - [Agent Safehouse](https://agent-safehouse.dev/) โ€” macOS kernel-level sandboxing for LLM coding agents via `sandbox-exec`. Uses a **deny-first model**: everything is blocked by default and only explicitly listed paths are opened up. This gives you theoretically stricter control (e.g. `~/Library` is fully blocked and only specific subdirs are allowed), but requires more configuration โ€” tools and runtimes that need paths you haven't whitelisted will break silently. If you need that level of precision and are willing to tune profiles per tool, Agent Safehouse may be the better fit. bx uses the opposite **allow-first model** (only sensitive paths are blocked), which works out of the box for VSCode, shells, Claude Code, and other tools without any per-tool configuration.
418
+ - [Docker AI Sandboxes](https://docs.docker.com/ai/sandboxes/) โ€” Docker's built-in sandbox environment for AI coding agents. Runs tools in isolated containers with controlled filesystem and network access. Stronger isolation than kernel-level sandboxing, but requires Docker Desktop and adds container overhead.
400
419
  - **Docker / VMs** โ€” for stronger isolation, run AI tools in a virtualized environment (containers, VMs). Full process and network isolation at the cost of setup overhead.
401
420
  - **Web sandboxes** โ€” browser-based approaches for running AI agents. See Simon Willison's [Living dangerously with Claude](https://simonwillison.net/2025/Oct/22/living-dangerously-with-claude/) for an overview.
402
421
 
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) || !statSync(absolute).isDirectory()) 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
  }
1146
- function collectReadOnlyDotfiles(home) {
1147
- return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f));
1216
+ function isOverridden(path, overrides) {
1217
+ return overrides.has(path) || overrides.has(realpathSafe(path));
1148
1218
  }
1149
- function collectIgnoredPaths(home, workDirs) {
1219
+ function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
1220
+ return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !isOverridden(p, overrides));
1221
+ }
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
- ];
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) ignored.push(...resolveGlobMatches(line, home));
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) {
@@ -1199,7 +1273,7 @@ function collectSystemDenyPaths(home) {
1199
1273
  function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "", readOnlyFiles = []) {
1200
1274
  const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
1201
1275
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1202
- const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1276
+ const readOnlyRules = sbplDenyBlock("Read-only paths", "file-write*", readOnlyDirs.map(sbplPathRule));
1203
1277
  const readOnlyFileRules = sbplDenyBlock("Read-only home dotfiles (shell init - write-protected against injection)", "file-write*", readOnlyFiles.map(sbplLiteral));
1204
1278
  const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
1205
1279
  return `; Auto-generated sandbox profile
@@ -1623,8 +1697,8 @@ Configuration:
1623
1697
  built-in apps (code, xcode) can be overridden
1624
1698
  ~/.bxignore sandbox rules (one per line):
1625
1699
  path block access (deny)
1626
- rw:path allow read-write access
1627
- ro:path allow read-only access
1700
+ rw:path allow read-write access (overrides built-in protection)
1701
+ ro:path allow read-only access (overrides built-in protection)
1628
1702
  <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
1629
1703
  / or . self-protect: block entire directory
1630
1704
  <dir>/.bxprotect marker file: block the containing directory
@@ -1699,7 +1773,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1699
1773
  }
1700
1774
  //#endregion
1701
1775
  //#region src/index.ts
1702
- const VERSION = "1.3.0";
1776
+ const VERSION = "1.5.1";
1703
1777
  const __dirname = dirname(fileURLToPath(import.meta.url));
1704
1778
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1705
1779
  console.log(`bx ${VERSION}`);
@@ -1748,9 +1822,11 @@ async function main() {
1748
1822
  const profileSandbox = vscodeUserFlag !== false ? vscodeUserFlag : app?.profile ?? false;
1749
1823
  if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
1750
1824
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1751
- const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1752
- const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
1753
- const readOnlyDotfiles = collectReadOnlyDotfiles(HOME);
1825
+ const allAccessible = new Set([...allowed, ...readOnly]);
1826
+ warnDangerousOverrides(HOME, allAccessible);
1827
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
1828
+ const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible, readOnly);
1829
+ const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
1754
1830
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1755
1831
  const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
1756
1832
  if (verbose) {
@@ -1877,6 +1953,14 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1877
1953
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1878
1954
  console.error(fmt.detail(parts.join(" ยท ")));
1879
1955
  }
1956
+ function warnDangerousOverrides(home, accessible) {
1957
+ const hits = [
1958
+ ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1959
+ ...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
1960
+ ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d))
1961
+ ].filter((p) => accessible.has(p));
1962
+ for (const p of hits) console.error(fmt.detail(`warning: ~/.bxignore override exposes built-in protected path ${p}`));
1963
+ }
1880
1964
  function printLaunchDetails(cmd, cwd) {
1881
1965
  const quote = (a) => JSON.stringify(a);
1882
1966
  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.3.0",
3
+ "version": "1.5.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"