bx-mac 1.2.0 → 1.4.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 +22 -3
  2. package/dist/bx.js +52 -15
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![license](https://img.shields.io/github/license/holtwick/bx-mac)](https://github.com/holtwick/bx-mac/blob/master/LICENSE)
6
6
  [![macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://github.com/holtwick/bx-mac)
7
7
 
8
- > **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.
8
+ > **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. Not a vault, but a reasonable safety net.
9
9
 
10
10
  ## 🤔 Why?
11
11
 
@@ -45,7 +45,23 @@ bx ~/work/my-project ~/work/shared-lib
45
45
  - **macOS only** — relies on `sandbox-exec` (Apple-specific)
46
46
  - **Not dynamic** — the sandbox profile is a snapshot of `$HOME` at launch time; directories or files created later are **not** automatically blocked
47
47
  - **File names visible** — blocked files cannot be read or written, but their names still appear in directory listings (a kernel-level `readdir` constraint, same as `chmod 000`)
48
- - **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
48
+ - **Not a vault** — this is a safety net, not airtight isolation (see [Security model](#-security-model-allow-first))
49
+
50
+ ## 🧱 Security model: allow-first
51
+
52
+ bx uses an **allow-first / blocklist** approach: everything is accessible by default, and only sensitive paths are explicitly blocked. This is the opposite of a deny-first / allowlist model where everything is blocked and only specific paths are opened up.
53
+
54
+ **Why allow-first?** Developer tools require access to an enormous and ever-changing set of paths -- dotfiles, `~/Library`, runtimes, caches, toolchains. A deny-first model would require new allow rules for every tool or framework update, breaking silently when a path is missing. The allow-first model works out of the box without per-tool tuning.
55
+
56
+ **What this means in practice:**
57
+
58
+ - bx provides **reasonable protection** against accidental or misguided file access -- not airtight isolation
59
+ - Sensitive paths (credentials, personal data, other projects) are explicitly blocked
60
+ - Paths that are not on the blocklist remain accessible -- including parts of `~/Library` and most dotfiles
61
+ - The sandbox profile is a **snapshot at launch time** -- files created later are not protected
62
+ - `sandbox-exec` itself is undocumented Apple API that could change with OS updates
63
+
64
+ If you need stricter, deny-first isolation, consider [Agent Safehouse](https://agent-safehouse.dev/) or a Docker/VM-based approach (see [Alternatives](#-alternatives)). bx is designed for the common case: keep AI tools and editors functional while blocking access to things they should never touch.
49
65
 
50
66
  ## 📥 Install
51
67
 
@@ -227,6 +243,8 @@ ro:reference/docs
227
243
  ro:shared/toolchain
228
244
  ```
229
245
 
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.
247
+
230
248
  Deny rules are applied **in addition** to the built-in protected lists:
231
249
 
232
250
  > 🔒 **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
@@ -380,7 +398,8 @@ These are great when available, but they only protect within their own tool. bx
380
398
 
381
399
  ## 🔗 Alternatives
382
400
 
383
- - [Agent Safehouse](https://agent-safehouse.dev/) — macOS kernel-level sandboxing for LLM coding agents via `sandbox-exec`. Deny-first model that blocks write access outside the project directory.
401
+ - [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.
402
+ - [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.
384
403
  - **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.
385
404
  - **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.
386
405
 
package/dist/bx.js CHANGED
@@ -953,13 +953,43 @@ function resolveAppPath(app) {
953
953
  //#endregion
954
954
  //#region src/profile.ts
955
955
  const PROTECTED_DOTDIRS = [
956
+ ".Trash",
956
957
  ".ssh",
957
958
  ".gnupg",
958
959
  ".docker",
959
960
  ".zsh_sessions",
960
961
  ".cargo",
961
962
  ".gradle",
962
- ".gem"
963
+ ".gem",
964
+ ".aws",
965
+ ".azure",
966
+ ".azd",
967
+ ".kube",
968
+ ".config/gcloud"
969
+ ];
970
+ const PROTECTED_HOME_DOTFILES = [
971
+ ".zsh_history",
972
+ ".bash_history",
973
+ ".sh_history",
974
+ ".node_repl_history",
975
+ ".python_history",
976
+ ".netrc",
977
+ ".git-credentials",
978
+ ".npmrc",
979
+ ".pypirc",
980
+ ".extra"
981
+ ];
982
+ const PROTECTED_HOME_DOTFILES_RO = [
983
+ ".zshrc",
984
+ ".zprofile",
985
+ ".zshenv",
986
+ ".zlogin",
987
+ ".zlogout",
988
+ ".bashrc",
989
+ ".bash_profile",
990
+ ".bash_login",
991
+ ".profile",
992
+ ".config/fish/config.fish"
963
993
  ];
964
994
  const PROTECTED_LIBRARY_DIRS = [
965
995
  "Accounts",
@@ -1061,7 +1091,7 @@ function parseHomeConfig(home, workDirs) {
1061
1091
  if (!match) continue;
1062
1092
  const [, prefix, rawPath] = match;
1063
1093
  const absolute = resolve(home, rawPath.trim());
1064
- if (!existsSync(absolute) || !statSync(absolute).isDirectory()) continue;
1094
+ if (!existsSync(absolute)) continue;
1065
1095
  if (prefix.toUpperCase() === "RW") allowed.add(absolute);
1066
1096
  else readOnly.add(absolute);
1067
1097
  }
@@ -1113,16 +1143,20 @@ function collectProtectedContainers(home) {
1113
1143
  }
1114
1144
  return matched;
1115
1145
  }
1116
- function collectIgnoredPaths(home, workDirs) {
1146
+ function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
1147
+ return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !overrides.has(p));
1148
+ }
1149
+ function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set()) {
1117
1150
  const ignored = [
1118
1151
  ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1152
+ ...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
1119
1153
  ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
1120
1154
  ...new Set(collectProtectedContainers(home))
1121
- ];
1155
+ ].filter((p) => !overrides.has(p));
1122
1156
  const globalIgnore = join(home, ".bxignore");
1123
1157
  if (existsSync(globalIgnore)) {
1124
1158
  const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
1125
- for (const line of denyLines) ignored.push(...resolveGlobMatches(line, home));
1159
+ for (const line of denyLines) for (const m of resolveGlobMatches(line, home)) if (!overrides.has(m)) ignored.push(m);
1126
1160
  }
1127
1161
  for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
1128
1162
  return ignored;
@@ -1162,17 +1196,18 @@ function collectSystemDenyPaths(home) {
1162
1196
  } catch {}
1163
1197
  return paths;
1164
1198
  }
1165
- function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1199
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "", readOnlyFiles = []) {
1166
1200
  const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
1167
1201
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1168
- const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1202
+ const readOnlyRules = sbplDenyBlock("Read-only paths", "file-write*", readOnlyDirs.map(sbplPathRule));
1203
+ const readOnlyFileRules = sbplDenyBlock("Read-only home dotfiles (shell init - write-protected against injection)", "file-write*", readOnlyFiles.map(sbplLiteral));
1169
1204
  const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
1170
1205
  return `; Auto-generated sandbox profile
1171
1206
  ; Working directories: ${workDirs.join(", ")}
1172
1207
 
1173
1208
  (version 1)
1174
1209
  (allow default)
1175
- ${blockedRules}${ignoredRules}${readOnlyRules}${systemRules}
1210
+ ${blockedRules}${ignoredRules}${readOnlyRules}${readOnlyFileRules}${systemRules}
1176
1211
  `;
1177
1212
  }
1178
1213
  //#endregion
@@ -1588,8 +1623,8 @@ Configuration:
1588
1623
  built-in apps (code, xcode) can be overridden
1589
1624
  ~/.bxignore sandbox rules (one per line):
1590
1625
  path block access (deny)
1591
- rw:path allow read-write access
1592
- ro:path allow read-only access
1626
+ rw:path allow read-write access (overrides built-in protection)
1627
+ ro:path allow read-only access (overrides built-in protection)
1593
1628
  <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
1594
1629
  / or . self-protect: block entire directory
1595
1630
  <dir>/.bxprotect marker file: block the containing directory
@@ -1664,7 +1699,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1664
1699
  }
1665
1700
  //#endregion
1666
1701
  //#region src/index.ts
1667
- const VERSION = "1.2.0";
1702
+ const VERSION = "1.4.0";
1668
1703
  const __dirname = dirname(fileURLToPath(import.meta.url));
1669
1704
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1670
1705
  console.log(`bx ${VERSION}`);
@@ -1713,10 +1748,12 @@ async function main() {
1713
1748
  const profileSandbox = vscodeUserFlag !== false ? vscodeUserFlag : app?.profile ?? false;
1714
1749
  if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
1715
1750
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1716
- const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1717
- const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
1751
+ const allAccessible = new Set([...allowed, ...readOnly]);
1752
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
1753
+ const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible);
1754
+ const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
1718
1755
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1719
- const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME);
1756
+ const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
1720
1757
  if (verbose) {
1721
1758
  console.error("\n--- Generated sandbox profile ---");
1722
1759
  console.error(profile);
@@ -1837,7 +1874,7 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1837
1874
  console.error(`\n${fmt.info(`${mode} → ${dirLabel}`)}`);
1838
1875
  const parts = [`${blockedDirs.length} blocked`, `${ignoredPaths.length} hidden`];
1839
1876
  if (readOnly.size > 0) parts.push(`${readOnly.size} read-only`);
1840
- const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
1877
+ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length - PROTECTED_HOME_DOTFILES.length;
1841
1878
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1842
1879
  console.error(fmt.detail(parts.join(" · ")));
1843
1880
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "darwin"
48
48
  ],
49
49
  "devDependencies": {
50
- "@types/node": "^25.5.0",
50
+ "@types/node": "^25.5.2",
51
51
  "rolldown": "^1.0.0-rc.13",
52
52
  "smol-toml": "^1.6.1",
53
53
  "vitest": "^4.1.2"