bx-mac 1.1.0 → 1.2.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 +3 -0
  2. package/dist/bx.js +46 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,6 +44,7 @@ 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
+ - **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`)
47
48
  - **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
48
49
 
49
50
  ## 📥 Install
@@ -380,6 +381,8 @@ These are great when available, but they only protect within their own tool. bx
380
381
  ## 🔗 Alternatives
381
382
 
382
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.
384
+ - **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
+ - **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
386
 
384
387
  ## 💛 Sponsor
385
388
 
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
  }
@@ -1084,9 +1087,12 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
1084
1087
  } catch {
1085
1088
  continue;
1086
1089
  }
1087
- if (!isDir) continue;
1088
1090
  if (parentDir === home && name === "Library") continue;
1089
1091
  if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
1092
+ if (!isDir) {
1093
+ blocked.push(fullPath);
1094
+ continue;
1095
+ }
1090
1096
  const status = isAllowedOrAncestor(fullPath, allowedDirs);
1091
1097
  if (status === "allowed") continue;
1092
1098
  if (status === "ancestor") {
@@ -1122,7 +1128,7 @@ function collectIgnoredPaths(home, workDirs) {
1122
1128
  return ignored;
1123
1129
  }
1124
1130
  function sbplEscape(path) {
1125
- return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
1131
+ return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
1126
1132
  }
1127
1133
  function sbplSubpath(path) {
1128
1134
  return ` (subpath "${sbplEscape(path)}")`;
@@ -1157,7 +1163,7 @@ function collectSystemDenyPaths(home) {
1157
1163
  return paths;
1158
1164
  }
1159
1165
  function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1160
- const blockedRules = sbplDenyBlock("Blocked directories (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplSubpath));
1166
+ const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
1161
1167
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1162
1168
  const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1163
1169
  const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
@@ -1405,10 +1411,20 @@ function buildCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1405
1411
  }
1406
1412
  function buildBuiltinCommand(mode, appArgs) {
1407
1413
  switch (mode) {
1408
- case "term": return {
1409
- bin: process$1.env.SHELL ?? "/bin/zsh",
1410
- args: ["-l"]
1411
- };
1414
+ case "term": {
1415
+ const shell = process$1.env.SHELL ?? "/bin/zsh";
1416
+ if (!existsSync(shell)) {
1417
+ console.error(fmt.warn(`shell not found: ${shell}, falling back to /bin/zsh`));
1418
+ return {
1419
+ bin: "/bin/zsh",
1420
+ args: ["-l"]
1421
+ };
1422
+ }
1423
+ return {
1424
+ bin: shell,
1425
+ args: ["-l"]
1426
+ };
1427
+ }
1412
1428
  case "claude": return {
1413
1429
  bin: "claude",
1414
1430
  args: []
@@ -1594,7 +1610,7 @@ function kindIcon(kind) {
1594
1610
  default: return `${RED}✖${RESET}`;
1595
1611
  }
1596
1612
  }
1597
- function insertPath(root, homeParts, absPath, kind, isDir) {
1613
+ function insertPath(root, absPath, kind, isDir) {
1598
1614
  const parts = absPath.split("/").filter(Boolean);
1599
1615
  let node = root;
1600
1616
  for (const part of parts) {
@@ -1609,7 +1625,6 @@ function insertPath(root, homeParts, absPath, kind, isDir) {
1609
1625
  * Intermediate directories on the home path are kept as navigation context. */
1610
1626
  function pruneTree(node, currentParts, homeParts, depth) {
1611
1627
  if (node.kind) return true;
1612
- depth < homeParts.length && (currentParts[depth], homeParts[depth]);
1613
1628
  for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
1614
1629
  return node.children.size > 0;
1615
1630
  }
@@ -1617,7 +1632,7 @@ function isDirectory(path) {
1617
1632
  try {
1618
1633
  return statSync(path).isDirectory();
1619
1634
  } catch {
1620
- return path.slice(path.lastIndexOf("/") + 1).startsWith(".");
1635
+ return true;
1621
1636
  }
1622
1637
  }
1623
1638
  function printNode(node, prefix) {
@@ -1637,11 +1652,11 @@ function printNode(node, prefix) {
1637
1652
  function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
1638
1653
  const root = { children: /* @__PURE__ */ new Map() };
1639
1654
  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);
1655
+ for (const dir of blockedDirs) insertPath(root, dir, "blocked", isDirectory(dir));
1656
+ for (const path of ignoredPaths) insertPath(root, path, "ignored", isDirectory(path));
1657
+ for (const dir of readOnlyDirs) insertPath(root, dir, "read-only", true);
1658
+ for (const dir of workDirs) insertPath(root, dir, "workdir", true);
1659
+ for (const dir of systemDenyPaths) insertPath(root, dir, "blocked", true);
1645
1660
  pruneTree(root, [], homeParts, 0);
1646
1661
  console.log(`\n${CYAN}/${RESET}`);
1647
1662
  printNode(root, "");
@@ -1649,7 +1664,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1649
1664
  }
1650
1665
  //#endregion
1651
1666
  //#region src/index.ts
1652
- const VERSION = "1.1.0";
1667
+ const VERSION = "1.2.0";
1653
1668
  const __dirname = dirname(fileURLToPath(import.meta.url));
1654
1669
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1655
1670
  console.log(`bx ${VERSION}`);
@@ -1675,6 +1690,10 @@ async function main() {
1675
1690
  const { mode, workArgs, verbose, dry, vscodeUser: vscodeUserFlag, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1676
1691
  const app = apps[mode];
1677
1692
  const workDirs = expandGlobs(implicit && app?.paths?.length ? app.paths : workArgs, HOME).map((a) => realpathSync(resolve(a)));
1693
+ if (workDirs.length === 0) {
1694
+ console.error(`\n${fmt.error("no matching working directories found")}\n`);
1695
+ process$1.exit(1);
1696
+ }
1678
1697
  if (implicit && !app?.paths?.length) {
1679
1698
  if (workDirs.some((d) => d === HOME)) {
1680
1699
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
@@ -1755,7 +1774,12 @@ async function main() {
1755
1774
  CODEBOX_SANDBOX: "1"
1756
1775
  }
1757
1776
  });
1777
+ child.on("error", (err) => {
1778
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1779
+ process$1.exit(1);
1780
+ });
1758
1781
  child.unref();
1782
+ closeSync(logFd);
1759
1783
  bringAppToFront(mode, apps);
1760
1784
  console.error(fmt.info(`running in background (pid ${child.pid})`));
1761
1785
  console.error(fmt.detail(`log: ${logPath}`));
@@ -1779,7 +1803,6 @@ async function main() {
1779
1803
  CODEBOX_SANDBOX: "1"
1780
1804
  }
1781
1805
  });
1782
- bringAppToFront(mode, apps);
1783
1806
  const cleanup = () => {
1784
1807
  try {
1785
1808
  rmSync(tmpDir, {
@@ -1789,6 +1812,11 @@ async function main() {
1789
1812
  } catch {}
1790
1813
  };
1791
1814
  process$1.on("exit", cleanup);
1815
+ child.on("error", (err) => {
1816
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1817
+ process$1.exit(1);
1818
+ });
1819
+ bringAppToFront(mode, apps);
1792
1820
  child.on("close", (code) => {
1793
1821
  process$1.exit(code ?? 0);
1794
1822
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {