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.
- package/README.md +22 -3
- package/dist/bx.js +87 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/holtwick/bx-mac/blob/master/LICENSE)
|
|
6
6
|
[](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
|
-
- **
|
|
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`.
|
|
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
|
|
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":
|
|
1409
|
-
|
|
1410
|
-
|
|
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,
|
|
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
|
|
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,
|
|
1641
|
-
for (const path of ignoredPaths) insertPath(root,
|
|
1642
|
-
for (const dir of readOnlyDirs) insertPath(root,
|
|
1643
|
-
for (const dir of workDirs) insertPath(root,
|
|
1644
|
-
for (const dir of systemDenyPaths) insertPath(root,
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|