bx-mac 1.1.0 → 1.3.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 +87 -23
  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
 
@@ -44,7 +44,24 @@ bx ~/work/my-project ~/work/shared-lib
44
44
  - **No protection against root/sudo** — the sandbox applies to the user-level process
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
- - **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
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** — 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.
48
65
 
49
66
  ## 📥 Install
50
67
 
@@ -379,7 +396,9 @@ These are great when available, but they only protect within their own tool. bx
379
396
 
380
397
  ## 🔗 Alternatives
381
398
 
382
- - [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.
399
+ - [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.
400
+ - **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
+ - **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.
383
402
 
384
403
  ## 💛 Sponsor
385
404
 
package/dist/bx.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { accessSync, closeSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { execFileSync, execSync, spawn } from "node:child_process";
5
5
  import { createInterface } from "node:readline";
@@ -811,12 +811,15 @@ function parsePassPaths(val) {
811
811
  if (typeof val === "number" && Number.isInteger(val) && val > 0) return val;
812
812
  if (Array.isArray(val)) {
813
813
  const paths = val.filter((a) => typeof a === "string");
814
+ if (val.length !== paths.length) console.error(fmt.warn("non-string items in array config were ignored"));
814
815
  if (paths.length > 0) return paths;
816
+ return false;
815
817
  }
816
818
  }
817
819
  function parseStringArray(val) {
818
820
  if (Array.isArray(val)) {
819
821
  const items = val.filter((a) => typeof a === "string");
822
+ if (val.length !== items.length) console.error(fmt.warn("non-string items in array config were ignored"));
820
823
  if (items.length > 0) return items;
821
824
  }
822
825
  }
@@ -950,13 +953,43 @@ function resolveAppPath(app) {
950
953
  //#endregion
951
954
  //#region src/profile.ts
952
955
  const PROTECTED_DOTDIRS = [
956
+ ".Trash",
953
957
  ".ssh",
954
958
  ".gnupg",
955
959
  ".docker",
956
960
  ".zsh_sessions",
957
961
  ".cargo",
958
962
  ".gradle",
959
- ".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"
960
993
  ];
961
994
  const PROTECTED_LIBRARY_DIRS = [
962
995
  "Accounts",
@@ -1084,9 +1117,12 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
1084
1117
  } catch {
1085
1118
  continue;
1086
1119
  }
1087
- if (!isDir) continue;
1088
1120
  if (parentDir === home && name === "Library") continue;
1089
1121
  if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
1122
+ if (!isDir) {
1123
+ blocked.push(fullPath);
1124
+ continue;
1125
+ }
1090
1126
  const status = isAllowedOrAncestor(fullPath, allowedDirs);
1091
1127
  if (status === "allowed") continue;
1092
1128
  if (status === "ancestor") {
@@ -1107,9 +1143,13 @@ function collectProtectedContainers(home) {
1107
1143
  }
1108
1144
  return matched;
1109
1145
  }
1146
+ function collectReadOnlyDotfiles(home) {
1147
+ return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f));
1148
+ }
1110
1149
  function collectIgnoredPaths(home, workDirs) {
1111
1150
  const ignored = [
1112
1151
  ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1152
+ ...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
1113
1153
  ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
1114
1154
  ...new Set(collectProtectedContainers(home))
1115
1155
  ];
@@ -1122,7 +1162,7 @@ function collectIgnoredPaths(home, workDirs) {
1122
1162
  return ignored;
1123
1163
  }
1124
1164
  function sbplEscape(path) {
1125
- return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
1165
+ return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
1126
1166
  }
1127
1167
  function sbplSubpath(path) {
1128
1168
  return ` (subpath "${sbplEscape(path)}")`;
@@ -1156,17 +1196,18 @@ function collectSystemDenyPaths(home) {
1156
1196
  } catch {}
1157
1197
  return paths;
1158
1198
  }
1159
- function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1160
- const blockedRules = sbplDenyBlock("Blocked directories (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplSubpath));
1199
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "", readOnlyFiles = []) {
1200
+ const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
1161
1201
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1162
1202
  const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1203
+ const readOnlyFileRules = sbplDenyBlock("Read-only home dotfiles (shell init - write-protected against injection)", "file-write*", readOnlyFiles.map(sbplLiteral));
1163
1204
  const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
1164
1205
  return `; Auto-generated sandbox profile
1165
1206
  ; Working directories: ${workDirs.join(", ")}
1166
1207
 
1167
1208
  (version 1)
1168
1209
  (allow default)
1169
- ${blockedRules}${ignoredRules}${readOnlyRules}${systemRules}
1210
+ ${blockedRules}${ignoredRules}${readOnlyRules}${readOnlyFileRules}${systemRules}
1170
1211
  `;
1171
1212
  }
1172
1213
  //#endregion
@@ -1405,10 +1446,20 @@ function buildCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1405
1446
  }
1406
1447
  function buildBuiltinCommand(mode, appArgs) {
1407
1448
  switch (mode) {
1408
- case "term": return {
1409
- bin: process$1.env.SHELL ?? "/bin/zsh",
1410
- args: ["-l"]
1411
- };
1449
+ case "term": {
1450
+ const shell = process$1.env.SHELL ?? "/bin/zsh";
1451
+ if (!existsSync(shell)) {
1452
+ console.error(fmt.warn(`shell not found: ${shell}, falling back to /bin/zsh`));
1453
+ return {
1454
+ bin: "/bin/zsh",
1455
+ args: ["-l"]
1456
+ };
1457
+ }
1458
+ return {
1459
+ bin: shell,
1460
+ args: ["-l"]
1461
+ };
1462
+ }
1412
1463
  case "claude": return {
1413
1464
  bin: "claude",
1414
1465
  args: []
@@ -1594,7 +1645,7 @@ function kindIcon(kind) {
1594
1645
  default: return `${RED}✖${RESET}`;
1595
1646
  }
1596
1647
  }
1597
- function insertPath(root, homeParts, absPath, kind, isDir) {
1648
+ function insertPath(root, absPath, kind, isDir) {
1598
1649
  const parts = absPath.split("/").filter(Boolean);
1599
1650
  let node = root;
1600
1651
  for (const part of parts) {
@@ -1609,7 +1660,6 @@ function insertPath(root, homeParts, absPath, kind, isDir) {
1609
1660
  * Intermediate directories on the home path are kept as navigation context. */
1610
1661
  function pruneTree(node, currentParts, homeParts, depth) {
1611
1662
  if (node.kind) return true;
1612
- depth < homeParts.length && (currentParts[depth], homeParts[depth]);
1613
1663
  for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
1614
1664
  return node.children.size > 0;
1615
1665
  }
@@ -1617,7 +1667,7 @@ function isDirectory(path) {
1617
1667
  try {
1618
1668
  return statSync(path).isDirectory();
1619
1669
  } catch {
1620
- return path.slice(path.lastIndexOf("/") + 1).startsWith(".");
1670
+ return true;
1621
1671
  }
1622
1672
  }
1623
1673
  function printNode(node, prefix) {
@@ -1637,11 +1687,11 @@ function printNode(node, prefix) {
1637
1687
  function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
1638
1688
  const root = { children: /* @__PURE__ */ new Map() };
1639
1689
  const homeParts = home.split("/").filter(Boolean);
1640
- for (const dir of blockedDirs) insertPath(root, homeParts, dir, "blocked", true);
1641
- for (const path of ignoredPaths) insertPath(root, homeParts, path, "ignored", isDirectory(path));
1642
- for (const dir of readOnlyDirs) insertPath(root, homeParts, dir, "read-only", true);
1643
- for (const dir of workDirs) insertPath(root, homeParts, dir, "workdir", true);
1644
- for (const dir of systemDenyPaths) insertPath(root, homeParts, dir, "blocked", true);
1690
+ for (const dir of blockedDirs) insertPath(root, dir, "blocked", isDirectory(dir));
1691
+ for (const path of ignoredPaths) insertPath(root, path, "ignored", isDirectory(path));
1692
+ for (const dir of readOnlyDirs) insertPath(root, dir, "read-only", true);
1693
+ for (const dir of workDirs) insertPath(root, dir, "workdir", true);
1694
+ for (const dir of systemDenyPaths) insertPath(root, dir, "blocked", true);
1645
1695
  pruneTree(root, [], homeParts, 0);
1646
1696
  console.log(`\n${CYAN}/${RESET}`);
1647
1697
  printNode(root, "");
@@ -1649,7 +1699,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1649
1699
  }
1650
1700
  //#endregion
1651
1701
  //#region src/index.ts
1652
- const VERSION = "1.1.0";
1702
+ const VERSION = "1.3.0";
1653
1703
  const __dirname = dirname(fileURLToPath(import.meta.url));
1654
1704
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1655
1705
  console.log(`bx ${VERSION}`);
@@ -1675,6 +1725,10 @@ async function main() {
1675
1725
  const { mode, workArgs, verbose, dry, vscodeUser: vscodeUserFlag, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1676
1726
  const app = apps[mode];
1677
1727
  const workDirs = expandGlobs(implicit && app?.paths?.length ? app.paths : workArgs, HOME).map((a) => realpathSync(resolve(a)));
1728
+ if (workDirs.length === 0) {
1729
+ console.error(`\n${fmt.error("no matching working directories found")}\n`);
1730
+ process$1.exit(1);
1731
+ }
1678
1732
  if (implicit && !app?.paths?.length) {
1679
1733
  if (workDirs.some((d) => d === HOME)) {
1680
1734
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
@@ -1696,8 +1750,9 @@ async function main() {
1696
1750
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1697
1751
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1698
1752
  const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
1753
+ const readOnlyDotfiles = collectReadOnlyDotfiles(HOME);
1699
1754
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1700
- const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME);
1755
+ const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
1701
1756
  if (verbose) {
1702
1757
  console.error("\n--- Generated sandbox profile ---");
1703
1758
  console.error(profile);
@@ -1755,7 +1810,12 @@ async function main() {
1755
1810
  CODEBOX_SANDBOX: "1"
1756
1811
  }
1757
1812
  });
1813
+ child.on("error", (err) => {
1814
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1815
+ process$1.exit(1);
1816
+ });
1758
1817
  child.unref();
1818
+ closeSync(logFd);
1759
1819
  bringAppToFront(mode, apps);
1760
1820
  console.error(fmt.info(`running in background (pid ${child.pid})`));
1761
1821
  console.error(fmt.detail(`log: ${logPath}`));
@@ -1779,7 +1839,6 @@ async function main() {
1779
1839
  CODEBOX_SANDBOX: "1"
1780
1840
  }
1781
1841
  });
1782
- bringAppToFront(mode, apps);
1783
1842
  const cleanup = () => {
1784
1843
  try {
1785
1844
  rmSync(tmpDir, {
@@ -1789,6 +1848,11 @@ async function main() {
1789
1848
  } catch {}
1790
1849
  };
1791
1850
  process$1.on("exit", cleanup);
1851
+ child.on("error", (err) => {
1852
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1853
+ process$1.exit(1);
1854
+ });
1855
+ bringAppToFront(mode, apps);
1792
1856
  child.on("close", (code) => {
1793
1857
  process$1.exit(code ?? 0);
1794
1858
  });
@@ -1809,7 +1873,7 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1809
1873
  console.error(`\n${fmt.info(`${mode} → ${dirLabel}`)}`);
1810
1874
  const parts = [`${blockedDirs.length} blocked`, `${ignoredPaths.length} hidden`];
1811
1875
  if (readOnly.size > 0) parts.push(`${readOnly.size} read-only`);
1812
- const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
1876
+ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length - PROTECTED_HOME_DOTFILES.length;
1813
1877
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1814
1878
  console.error(fmt.detail(parts.join(" · ")));
1815
1879
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "1.1.0",
3
+ "version": "1.3.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"