bx-mac 0.12.0 → 1.0.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 +16 -12
  2. package/dist/bx.js +74 -47
  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,10 +1,10 @@
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";
6
- import { fileURLToPath } from "node:url";
7
6
  import process$1 from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
8
  //#region node_modules/.pnpm/smol-toml@1.6.1/node_modules/smol-toml/dist/error.js
9
9
  /*!
10
10
  * Copyright (c) Squirrel Chat et al., All rights reserved.
@@ -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;
@@ -1209,17 +1219,22 @@ async function checkAppAlreadyRunning(mode, apps) {
1209
1219
  if (BUILTIN_MODES.includes(mode)) return;
1210
1220
  const app = apps[mode];
1211
1221
  if (!app?.bundle) return;
1212
- let running = false;
1222
+ let appName = mode;
1213
1223
  try {
1214
- running = execFileSync("lsappinfo", ["list"], {
1224
+ const list = execFileSync("lsappinfo", ["list"], {
1215
1225
  encoding: "utf-8",
1216
1226
  timeout: 3e3
1217
- }).includes(`bundleID="${app.bundle}"`);
1227
+ });
1228
+ if (!list.includes(`bundleID="${app.bundle}"`)) return;
1229
+ const idx = list.indexOf(`bundleID="${app.bundle}"`);
1230
+ const bundleLine = list.lastIndexOf("\n", idx);
1231
+ const prevLine = list.lastIndexOf("\n", bundleLine - 1);
1232
+ const nameMatch = list.slice(prevLine === -1 ? 0 : prevLine, bundleLine).match(/"([^"]+)"/);
1233
+ if (nameMatch) appName = nameMatch[1];
1218
1234
  } catch {
1219
1235
  return;
1220
1236
  }
1221
- if (!running) return;
1222
- console.error(`\n${fmt.warn(`"${mode}" is already running`)}`);
1237
+ console.error(`\n${fmt.warn(`"${appName}" is already running`)}`);
1223
1238
  console.error(fmt.detail("the workspace will open in the EXISTING instance — sandbox will NOT apply"));
1224
1239
  if (mode === "code") console.error(fmt.detail("quit the app first, or use --profile-sandbox for an isolated instance"));
1225
1240
  else console.error(fmt.detail("quit the app first to ensure sandbox protection"));
@@ -1228,7 +1243,7 @@ async function checkAppAlreadyRunning(mode, apps) {
1228
1243
  output: process$1.stderr
1229
1244
  });
1230
1245
  const answer = await new Promise((res) => {
1231
- rl.question(` continue without sandbox? [y/N] `, res);
1246
+ rl.question(` continue with existing instance? [y/N]`, res);
1232
1247
  });
1233
1248
  rl.close();
1234
1249
  if (!answer.match(/^y(es)?$/i)) process$1.exit(0);
@@ -1303,9 +1318,11 @@ function parseArgs(validModes) {
1303
1318
  function isBuiltinMode(mode) {
1304
1319
  return BUILTIN_MODES.includes(mode);
1305
1320
  }
1306
- function getWorkdirsToPass(app, workDirs) {
1307
- if (app.passWorkdirs === false) return [];
1308
- if (app.passWorkdirs === "first") return workDirs.slice(0, 1);
1321
+ function getPassPaths(app, workDirs, home) {
1322
+ const val = app.passPaths;
1323
+ if (val === false) return [];
1324
+ if (typeof val === "number") return workDirs.slice(0, val);
1325
+ if (Array.isArray(val)) return val.map((p) => p.replace(/^~\//, home + "/"));
1309
1326
  return workDirs;
1310
1327
  }
1311
1328
  function appBundleFromPath(path) {
@@ -1377,7 +1394,7 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1377
1394
  }
1378
1395
  if (app.args) args.push(...app.args);
1379
1396
  if (appArgs.length > 0) args.push(...appArgs);
1380
- args.push(...getWorkdirsToPass(app, workDirs));
1397
+ args.push(...getPassPaths(app, workDirs, home));
1381
1398
  return {
1382
1399
  bin,
1383
1400
  args
@@ -1480,7 +1497,7 @@ Configuration:
1480
1497
  binary = "..." relative path in .app bundle
1481
1498
  path = "..." explicit executable path
1482
1499
  args = ["..."] extra arguments
1483
- passWorkdirs = true|false|"first" pass workdirs as launch args
1500
+ passPaths = true|false|N|[...] paths passed as launch args
1484
1501
  background = true run in background by default
1485
1502
  built-in apps (code, xcode) can be overridden
1486
1503
  ~/.bxignore sandbox rules (one per line):
@@ -1561,12 +1578,9 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1561
1578
  console.log(`\n${RED}✖${RESET} = denied ${YELLOW}◉${RESET} = read-only ${GREEN}✔${RESET} = read-write\n`);
1562
1579
  }
1563
1580
  //#endregion
1564
- //#region package.json
1565
- var version = "0.12.0";
1566
- //#endregion
1567
1581
  //#region src/index.ts
1582
+ const VERSION = "1.0.1";
1568
1583
  const __dirname = dirname(fileURLToPath(import.meta.url));
1569
- const VERSION = version;
1570
1584
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1571
1585
  console.log(`bx ${VERSION}`);
1572
1586
  process$1.exit(0);
@@ -1581,26 +1595,24 @@ if (!process$1.env.HOME) {
1581
1595
  }
1582
1596
  const HOME = process$1.env.HOME;
1583
1597
  async function main() {
1598
+ checkOwnSandbox();
1599
+ checkExternalSandbox();
1584
1600
  const apps = getAvailableApps(loadConfig(HOME));
1585
1601
  const { mode, workArgs, verbose, dry, profileSandbox, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1586
1602
  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) {
1603
+ const workDirs = (implicit && app?.paths?.length ? app.paths : workArgs).map((a) => realpathSync(resolve(a.replace(/^~\//, HOME + "/"))));
1604
+ if (implicit && !app?.paths?.length) {
1589
1605
  if (workDirs.some((d) => d === HOME)) {
1590
1606
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
1591
1607
  console.error(fmt.detail(`Usage: bx ${mode} <workdir>`));
1592
- console.error(fmt.detail(`Config: set default workdirs in ~/.bxconfig.toml:\n`));
1608
+ console.error(fmt.detail(`Config: set default paths in ~/.bxconfig.toml:\n`));
1593
1609
  console.error(fmt.detail(`[${mode}]`));
1594
- console.error(fmt.detail(`workdirs = ["~/work/my-project"]\n`));
1610
+ console.error(fmt.detail(`paths = ["~/work/my-project"]\n`));
1595
1611
  process$1.exit(1);
1596
1612
  }
1597
1613
  if (!dry) await confirmLaunch(workDirs[0], mode);
1598
1614
  }
1599
- if (!dry) {
1600
- checkOwnSandbox();
1601
- checkVSCodeTerminal();
1602
- checkExternalSandbox();
1603
- }
1615
+ if (!dry) checkVSCodeTerminal();
1604
1616
  checkWorkDirs(workDirs, HOME);
1605
1617
  await checkAppAlreadyRunning(mode, apps);
1606
1618
  if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
@@ -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.1",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {