bx-mac 0.12.0 → 1.0.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 +16 -12
  2. package/dist/bx.js +59 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,7 +82,7 @@ For app modes, values before `--` define the sandbox scope (`workdir...`). Value
82
82
 
83
83
  For `xcode`, this distinction is important: the sandbox workdir is **not** passed as an Xcode open argument. Use `--` if you want to open a specific `.xcworkspace` or `.xcodeproj`.
84
84
 
85
- This behavior is configurable per app via `passWorkdirs` in `~/.bxconfig.toml` (default: `true`, built-in `xcode` default: `false`).
85
+ This behavior is configurable per app via `passPaths` in `~/.bxconfig.toml` (default: `true`, built-in `xcode` default: `false`).
86
86
 
87
87
  GUI app modes are activated in the foreground on launch (best effort), so the opened app should become the frontmost app.
88
88
 
@@ -159,44 +159,44 @@ path = "/usr/local/bin/code"
159
159
 
160
160
  | Field | Description |
161
161
  | --- | --- |
162
- | `mode` | Inherit from another app (e.g. `"code"`, `"cursor"`) — only `workdirs` / overrides needed |
162
+ | `mode` | Inherit from another app (e.g. `"code"`, `"cursor"`) — only `paths` / overrides needed |
163
163
  | `bundle` | macOS bundle identifier — used with `mdfind` to find the app automatically |
164
164
  | `binary` | Relative path to the executable inside the `.app` bundle |
165
165
  | `path` | Absolute path to the executable **or** `.app` bundle (highest priority, skips discovery) |
166
166
  | `fallback` | Absolute fallback path if `mdfind` discovery fails |
167
167
  | `args` | Extra arguments always passed to the app |
168
- | `passWorkdirs` | Whether `workdir...` is forwarded as app launch args (`true`/`false`/`"first"`) |
169
- | `workdirs` | Default working directories when none are given on the CLI (supports `~/` paths) |
168
+ | `passPaths` | Paths passed as app launch args (`true`/`false`/`N`/`["~/p1", "~/p2"]`) |
169
+ | `paths` | Default working directories when none are given on the CLI (supports `~/` paths) |
170
170
  | `background` | Run the app detached in the background by default (`true`/`false`) |
171
171
 
172
172
  **Resolution order:** `path` → `mdfind` by `bundle` + `binary` → `fallback`
173
173
 
174
- `passWorkdirs` controls launch argument behavior and is independent of sandbox scope. Even with `passWorkdirs = false`, the provided `workdir...` still defines what the sandbox can access. Use `passWorkdirs = "first"` to pass only the first workdir as a launch argument useful when the app should open one directory but the sandbox should grant access to multiple.
174
+ `passPaths` controls launch argument behavior and is independent of sandbox scope. Even with `passPaths = false`, the provided `workdir...` still defines what the sandbox can access. Use `passPaths = 1` to pass only the first path as a launch argument, or `passPaths = ["~/specific/path"]` to pass explicit paths instead of workdirs.
175
175
 
176
- **Workdir shortcuts with `mode`** let you create named entries that inherit everything from an existing app — just set `mode` and `workdirs`:
176
+ **Workdir shortcuts with `mode`** let you create named entries that inherit everything from an existing app — just set `mode` and `paths`:
177
177
 
178
178
  ```toml
179
179
  # "bx myproject" opens VSCode with these directories
180
180
  [myproject]
181
181
  mode = "code"
182
- workdirs = ["~/work/my-project", "~/work/shared-lib"]
182
+ paths = ["~/work/my-project", "~/work/shared-lib"]
183
183
 
184
184
  # "bx ios" opens Xcode with this directory
185
185
  [ios]
186
186
  mode = "xcode"
187
- workdirs = ["~/work/my-ios-app"]
187
+ paths = ["~/work/my-ios-app"]
188
188
  ```
189
189
 
190
190
  Running `bx myproject` inherits VSCode's bundle, binary, args, and everything else — no need to repeat the full app configuration. Own fields override inherited ones, so you can still customize specific settings. Chaining is supported (e.g. `myproject` → `cursor` → `code`).
191
191
 
192
- **Preconfigured workdirs** also work directly on app definitions:
192
+ **Preconfigured paths** also work directly on app definitions:
193
193
 
194
194
  ```toml
195
195
  [code]
196
- workdirs = ["~/work/my-project", "~/work/shared-lib"]
196
+ paths = ["~/work/my-project", "~/work/shared-lib"]
197
197
  ```
198
198
 
199
- Running `bx code` (without arguments) will then open VSCode with both directories sandboxed. CLI arguments always override configured workdirs.
199
+ Running `bx code` (without arguments) will then open VSCode with both directories sandboxed. CLI arguments always override configured paths.
200
200
 
201
201
  When overriding a built-in app, only the specified fields are replaced — unset fields keep their defaults. See [`bxconfig.example.toml`](bxconfig.example.toml) for a complete reference.
202
202
 
@@ -339,7 +339,7 @@ Or preconfigure them in `~/.bxconfig.toml`:
339
339
 
340
340
  ```toml
341
341
  [code]
342
- workdirs = ["~/work/project-a", "~/work/project-b"]
342
+ paths = ["~/work/project-a", "~/work/project-b"]
343
343
  ```
344
344
 
345
345
  For VSCode specifically, `--profile-sandbox` forces a separate Electron process via an isolated `--user-data-dir`, but this means separate extensions and settings.
@@ -372,6 +372,10 @@ Some AI coding tools ship with their own sandboxing. bx complements these by pro
372
372
 
373
373
  These are great when available, but they only protect within their own tool. bx wraps the entire process — so even if a tool's built-in sandbox is misconfigured, disabled, or absent, your files stay protected.
374
374
 
375
+ ## 🔗 Alternatives
376
+
377
+ - [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.
378
+
375
379
  ## 📄 License
376
380
 
377
381
  MIT — see [LICENSE](LICENSE).
package/dist/bx.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { accessSync, 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, spawn } from "node:child_process";
5
5
  import { createInterface } from "node:readline";
@@ -797,7 +797,7 @@ const BUILTIN_APPS = {
797
797
  bundle: "com.apple.dt.Xcode",
798
798
  binary: "Contents/MacOS/Xcode",
799
799
  fallback: "/Applications/Xcode.app/Contents/MacOS/Xcode",
800
- passWorkdirs: false
800
+ passPaths: false
801
801
  }
802
802
  };
803
803
  /** Shell-only built-in modes that are not app definitions */
@@ -806,6 +806,20 @@ const BUILTIN_MODES = [
806
806
  "claude",
807
807
  "exec"
808
808
  ];
809
+ function parsePassPaths(val) {
810
+ if (typeof val === "boolean") return val;
811
+ if (typeof val === "number" && Number.isInteger(val) && val > 0) return val;
812
+ if (Array.isArray(val)) {
813
+ const paths = val.filter((a) => typeof a === "string");
814
+ if (paths.length > 0) return paths;
815
+ }
816
+ }
817
+ function parseStringArray(val) {
818
+ if (Array.isArray(val)) {
819
+ const items = val.filter((a) => typeof a === "string");
820
+ if (items.length > 0) return items;
821
+ }
822
+ }
809
823
  function parseAppDef(def) {
810
824
  return {
811
825
  mode: typeof def.mode === "string" ? def.mode : void 0,
@@ -813,9 +827,9 @@ function parseAppDef(def) {
813
827
  binary: typeof def.binary === "string" ? def.binary : void 0,
814
828
  path: typeof def.path === "string" ? def.path : void 0,
815
829
  fallback: typeof def.fallback === "string" ? def.fallback : void 0,
816
- args: Array.isArray(def.args) ? def.args.filter((a) => typeof a === "string") : void 0,
817
- passWorkdirs: typeof def.passWorkdirs === "boolean" || def.passWorkdirs === "first" ? def.passWorkdirs : void 0,
818
- workdirs: Array.isArray(def.workdirs) ? def.workdirs.filter((a) => typeof a === "string") : void 0,
830
+ args: parseStringArray(def.args),
831
+ passPaths: parsePassPaths(def.passPaths ?? def.passWorkPaths ?? def.passWorkdirs),
832
+ paths: parseStringArray(def.paths ?? def.workdirs),
819
833
  background: typeof def.background === "boolean" ? def.background : void 0
820
834
  };
821
835
  }
@@ -836,8 +850,12 @@ function loadConfig(home) {
836
850
  "path",
837
851
  "fallback",
838
852
  "args",
853
+ "passPaths",
854
+ "passWorkPaths",
839
855
  "passWorkdirs",
840
- "workdirs"
856
+ "paths",
857
+ "workdirs",
858
+ "background"
841
859
  ]);
842
860
  for (const [key, val] of Object.entries(doc)) {
843
861
  if (key === "apps") continue;
@@ -1033,14 +1051,6 @@ const ACCESS_PREFIX_RE = /^(RW|RO):(.+)$/i;
1033
1051
  function parseHomeConfig(home, workDirs) {
1034
1052
  const allowed = new Set(workDirs);
1035
1053
  const readOnly = /* @__PURE__ */ new Set();
1036
- const bxallowPath = join(home, ".bxallow");
1037
- if (existsSync(bxallowPath)) {
1038
- console.error("sandbox: WARNING — ~/.bxallow is deprecated. Move entries to ~/.bxignore with RW: prefix.");
1039
- for (const line of parseLines(bxallowPath)) {
1040
- const absolute = resolve(home, line);
1041
- if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
1042
- }
1043
- }
1044
1054
  for (const line of parseLines(join(home, ".bxignore"))) {
1045
1055
  const match = line.match(ACCESS_PREFIX_RE);
1046
1056
  if (!match) continue;
@@ -1303,9 +1313,11 @@ function parseArgs(validModes) {
1303
1313
  function isBuiltinMode(mode) {
1304
1314
  return BUILTIN_MODES.includes(mode);
1305
1315
  }
1306
- function getWorkdirsToPass(app, workDirs) {
1307
- if (app.passWorkdirs === false) return [];
1308
- if (app.passWorkdirs === "first") return workDirs.slice(0, 1);
1316
+ function getPassPaths(app, workDirs, home) {
1317
+ const val = app.passPaths;
1318
+ if (val === false) return [];
1319
+ if (typeof val === "number") return workDirs.slice(0, val);
1320
+ if (Array.isArray(val)) return val.map((p) => p.replace(/^~\//, home + "/"));
1309
1321
  return workDirs;
1310
1322
  }
1311
1323
  function appBundleFromPath(path) {
@@ -1377,7 +1389,7 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1377
1389
  }
1378
1390
  if (app.args) args.push(...app.args);
1379
1391
  if (appArgs.length > 0) args.push(...appArgs);
1380
- args.push(...getWorkdirsToPass(app, workDirs));
1392
+ args.push(...getPassPaths(app, workDirs, home));
1381
1393
  return {
1382
1394
  bin,
1383
1395
  args
@@ -1480,7 +1492,7 @@ Configuration:
1480
1492
  binary = "..." relative path in .app bundle
1481
1493
  path = "..." explicit executable path
1482
1494
  args = ["..."] extra arguments
1483
- passWorkdirs = true|false|"first" pass workdirs as launch args
1495
+ passPaths = true|false|N|[...] paths passed as launch args
1484
1496
  background = true run in background by default
1485
1497
  built-in apps (code, xcode) can be overridden
1486
1498
  ~/.bxignore sandbox rules (one per line):
@@ -1562,7 +1574,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1562
1574
  }
1563
1575
  //#endregion
1564
1576
  //#region package.json
1565
- var version = "0.12.0";
1577
+ var version = "1.0.0";
1566
1578
  //#endregion
1567
1579
  //#region src/index.ts
1568
1580
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1584,14 +1596,14 @@ async function main() {
1584
1596
  const apps = getAvailableApps(loadConfig(HOME));
1585
1597
  const { mode, workArgs, verbose, dry, profileSandbox, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1586
1598
  const app = apps[mode];
1587
- const workDirs = (implicit && app?.workdirs?.length ? app.workdirs : workArgs).map((a) => resolve(a.replace(/^~\//, HOME + "/")));
1588
- if (implicit && !app?.workdirs?.length) {
1599
+ const workDirs = (implicit && app?.paths?.length ? app.paths : workArgs).map((a) => realpathSync(resolve(a.replace(/^~\//, HOME + "/"))));
1600
+ if (implicit && !app?.paths?.length) {
1589
1601
  if (workDirs.some((d) => d === HOME)) {
1590
1602
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
1591
1603
  console.error(fmt.detail(`Usage: bx ${mode} <workdir>`));
1592
- console.error(fmt.detail(`Config: set default workdirs in ~/.bxconfig.toml:\n`));
1604
+ console.error(fmt.detail(`Config: set default paths in ~/.bxconfig.toml:\n`));
1593
1605
  console.error(fmt.detail(`[${mode}]`));
1594
- console.error(fmt.detail(`workdirs = ["~/work/my-project"]\n`));
1606
+ console.error(fmt.detail(`paths = ["~/work/my-project"]\n`));
1595
1607
  process$1.exit(1);
1596
1608
  }
1597
1609
  if (!dry) await confirmLaunch(workDirs[0], mode);
@@ -1625,17 +1637,25 @@ async function main() {
1625
1637
  });
1626
1638
  process$1.exit(0);
1627
1639
  }
1628
- const profilePath = join("/tmp", `bx-${process$1.pid}.sb`);
1629
- writeFileSync(profilePath, profile);
1640
+ const tmpDir = mkdtempSync(join("/tmp", "bx-"));
1641
+ const profilePath = join(tmpDir, "profile.sb");
1642
+ writeFileSync(profilePath, profile, { mode: 384 });
1630
1643
  const cmd = buildCommand(mode, workDirs, HOME, profileSandbox, appArgs, apps);
1631
1644
  const background = backgroundFlag || app?.background === true;
1632
1645
  const nestedSandboxWarning = getNestedSandboxWarning(mode, apps);
1633
1646
  if (nestedSandboxWarning) console.error(fmt.detail(nestedSandboxWarning));
1634
- if (verbose) printLaunchDetails(cmd, workDirs[0], getActivationCommand(mode, apps));
1647
+ printLaunchDetails(cmd, workDirs[0]);
1648
+ if (verbose) {
1649
+ const activationCmd = getActivationCommand(mode, apps);
1650
+ if (activationCmd) {
1651
+ const quote = (a) => JSON.stringify(a);
1652
+ console.error(fmt.detail(`focus: ${activationCmd.bin} ${activationCmd.args.map(quote).join(" ")}`));
1653
+ }
1654
+ }
1635
1655
  console.error("");
1636
1656
  if (background) {
1637
- const logPath = join("/tmp", `bx-${process$1.pid}.log`);
1638
- const logFd = openSync(logPath, "a");
1657
+ const logPath = join(tmpDir, "bx.log");
1658
+ const logFd = openSync(logPath, "a", 384);
1639
1659
  const child = spawn("sandbox-exec", [
1640
1660
  "-f",
1641
1661
  profilePath,
@@ -1683,8 +1703,16 @@ async function main() {
1683
1703
  }
1684
1704
  });
1685
1705
  bringAppToFront(mode, apps);
1706
+ const cleanup = () => {
1707
+ try {
1708
+ rmSync(tmpDir, {
1709
+ recursive: true,
1710
+ force: true
1711
+ });
1712
+ } catch {}
1713
+ };
1714
+ process$1.on("exit", cleanup);
1686
1715
  child.on("close", (code) => {
1687
- rmSync(profilePath, { force: true });
1688
1716
  process$1.exit(code ?? 0);
1689
1717
  });
1690
1718
  }
@@ -1708,12 +1736,11 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1708
1736
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1709
1737
  console.error(fmt.detail(parts.join(" · ")));
1710
1738
  }
1711
- function printLaunchDetails(cmd, cwd, activationCmd) {
1739
+ function printLaunchDetails(cmd, cwd) {
1712
1740
  const quote = (a) => JSON.stringify(a);
1713
1741
  console.error(fmt.detail(`bin: ${cmd.bin}`));
1714
1742
  console.error(fmt.detail(`args: ${cmd.args.map(quote).join(" ") || "(none)"}`));
1715
1743
  console.error(fmt.detail(`cwd: ${cwd}`));
1716
- if (activationCmd) console.error(fmt.detail(`focus: ${activationCmd.bin} ${activationCmd.args.map(quote).join(" ")}`));
1717
1744
  }
1718
1745
  main();
1719
1746
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.12.0",
3
+ "version": "1.0.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {