bx-mac 1.2.0 → 1.4.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 +52 -15
- 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
|
|
|
@@ -45,7 +45,23 @@ bx ~/work/my-project ~/work/shared-lib
|
|
|
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** —
|
|
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.
|
|
49
65
|
|
|
50
66
|
## 📥 Install
|
|
51
67
|
|
|
@@ -227,6 +243,8 @@ ro:reference/docs
|
|
|
227
243
|
ro:shared/toolchain
|
|
228
244
|
```
|
|
229
245
|
|
|
246
|
+
`rw:` and `ro:` entries also **override** the built-in protected lists - e.g. `ro:.npmrc` makes the otherwise-blocked `~/.npmrc` readable, `rw:.aws` opens the AWS credentials directory. Files (not just directories) are accepted as targets. Use this with care - you are explicitly weakening the default protection.
|
|
247
|
+
|
|
230
248
|
Deny rules are applied **in addition** to the built-in protected lists:
|
|
231
249
|
|
|
232
250
|
> 🔒 **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
|
|
@@ -380,7 +398,8 @@ These are great when available, but they only protect within their own tool. bx
|
|
|
380
398
|
|
|
381
399
|
## 🔗 Alternatives
|
|
382
400
|
|
|
383
|
-
- [Agent Safehouse](https://agent-safehouse.dev/) — macOS kernel-level sandboxing for LLM coding agents via `sandbox-exec`.
|
|
401
|
+
- [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.
|
|
402
|
+
- [Docker AI Sandboxes](https://docs.docker.com/ai/sandboxes/) — Docker's built-in sandbox environment for AI coding agents. Runs tools in isolated containers with controlled filesystem and network access. Stronger isolation than kernel-level sandboxing, but requires Docker Desktop and adds container overhead.
|
|
384
403
|
- **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
404
|
- **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.
|
|
386
405
|
|
package/dist/bx.js
CHANGED
|
@@ -953,13 +953,43 @@ function resolveAppPath(app) {
|
|
|
953
953
|
//#endregion
|
|
954
954
|
//#region src/profile.ts
|
|
955
955
|
const PROTECTED_DOTDIRS = [
|
|
956
|
+
".Trash",
|
|
956
957
|
".ssh",
|
|
957
958
|
".gnupg",
|
|
958
959
|
".docker",
|
|
959
960
|
".zsh_sessions",
|
|
960
961
|
".cargo",
|
|
961
962
|
".gradle",
|
|
962
|
-
".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"
|
|
963
993
|
];
|
|
964
994
|
const PROTECTED_LIBRARY_DIRS = [
|
|
965
995
|
"Accounts",
|
|
@@ -1061,7 +1091,7 @@ function parseHomeConfig(home, workDirs) {
|
|
|
1061
1091
|
if (!match) continue;
|
|
1062
1092
|
const [, prefix, rawPath] = match;
|
|
1063
1093
|
const absolute = resolve(home, rawPath.trim());
|
|
1064
|
-
if (!existsSync(absolute)
|
|
1094
|
+
if (!existsSync(absolute)) continue;
|
|
1065
1095
|
if (prefix.toUpperCase() === "RW") allowed.add(absolute);
|
|
1066
1096
|
else readOnly.add(absolute);
|
|
1067
1097
|
}
|
|
@@ -1113,16 +1143,20 @@ function collectProtectedContainers(home) {
|
|
|
1113
1143
|
}
|
|
1114
1144
|
return matched;
|
|
1115
1145
|
}
|
|
1116
|
-
function
|
|
1146
|
+
function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
|
|
1147
|
+
return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !overrides.has(p));
|
|
1148
|
+
}
|
|
1149
|
+
function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set()) {
|
|
1117
1150
|
const ignored = [
|
|
1118
1151
|
...PROTECTED_DOTDIRS.map((d) => join(home, d)),
|
|
1152
|
+
...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
|
|
1119
1153
|
...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
|
|
1120
1154
|
...new Set(collectProtectedContainers(home))
|
|
1121
|
-
];
|
|
1155
|
+
].filter((p) => !overrides.has(p));
|
|
1122
1156
|
const globalIgnore = join(home, ".bxignore");
|
|
1123
1157
|
if (existsSync(globalIgnore)) {
|
|
1124
1158
|
const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
|
|
1125
|
-
for (const line of denyLines)
|
|
1159
|
+
for (const line of denyLines) for (const m of resolveGlobMatches(line, home)) if (!overrides.has(m)) ignored.push(m);
|
|
1126
1160
|
}
|
|
1127
1161
|
for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
|
|
1128
1162
|
return ignored;
|
|
@@ -1162,17 +1196,18 @@ function collectSystemDenyPaths(home) {
|
|
|
1162
1196
|
} catch {}
|
|
1163
1197
|
return paths;
|
|
1164
1198
|
}
|
|
1165
|
-
function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
|
|
1199
|
+
function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "", readOnlyFiles = []) {
|
|
1166
1200
|
const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
|
|
1167
1201
|
const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
|
|
1168
|
-
const readOnlyRules = sbplDenyBlock("Read-only
|
|
1202
|
+
const readOnlyRules = sbplDenyBlock("Read-only paths", "file-write*", readOnlyDirs.map(sbplPathRule));
|
|
1203
|
+
const readOnlyFileRules = sbplDenyBlock("Read-only home dotfiles (shell init - write-protected against injection)", "file-write*", readOnlyFiles.map(sbplLiteral));
|
|
1169
1204
|
const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
|
|
1170
1205
|
return `; Auto-generated sandbox profile
|
|
1171
1206
|
; Working directories: ${workDirs.join(", ")}
|
|
1172
1207
|
|
|
1173
1208
|
(version 1)
|
|
1174
1209
|
(allow default)
|
|
1175
|
-
${blockedRules}${ignoredRules}${readOnlyRules}${systemRules}
|
|
1210
|
+
${blockedRules}${ignoredRules}${readOnlyRules}${readOnlyFileRules}${systemRules}
|
|
1176
1211
|
`;
|
|
1177
1212
|
}
|
|
1178
1213
|
//#endregion
|
|
@@ -1588,8 +1623,8 @@ Configuration:
|
|
|
1588
1623
|
built-in apps (code, xcode) can be overridden
|
|
1589
1624
|
~/.bxignore sandbox rules (one per line):
|
|
1590
1625
|
path block access (deny)
|
|
1591
|
-
rw:path allow read-write access
|
|
1592
|
-
ro:path allow read-only access
|
|
1626
|
+
rw:path allow read-write access (overrides built-in protection)
|
|
1627
|
+
ro:path allow read-only access (overrides built-in protection)
|
|
1593
1628
|
<workdir>/.bxignore blocked paths in project (.gitignore-style matching)
|
|
1594
1629
|
/ or . self-protect: block entire directory
|
|
1595
1630
|
<dir>/.bxprotect marker file: block the containing directory
|
|
@@ -1664,7 +1699,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
|
|
|
1664
1699
|
}
|
|
1665
1700
|
//#endregion
|
|
1666
1701
|
//#region src/index.ts
|
|
1667
|
-
const VERSION = "1.
|
|
1702
|
+
const VERSION = "1.4.0";
|
|
1668
1703
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1669
1704
|
if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
|
|
1670
1705
|
console.log(`bx ${VERSION}`);
|
|
@@ -1713,10 +1748,12 @@ async function main() {
|
|
|
1713
1748
|
const profileSandbox = vscodeUserFlag !== false ? vscodeUserFlag : app?.profile ?? false;
|
|
1714
1749
|
if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
|
|
1715
1750
|
const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
|
|
1716
|
-
const
|
|
1717
|
-
const
|
|
1751
|
+
const allAccessible = new Set([...allowed, ...readOnly]);
|
|
1752
|
+
const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
|
|
1753
|
+
const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible);
|
|
1754
|
+
const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
|
|
1718
1755
|
printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
|
|
1719
|
-
const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME);
|
|
1756
|
+
const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
|
|
1720
1757
|
if (verbose) {
|
|
1721
1758
|
console.error("\n--- Generated sandbox profile ---");
|
|
1722
1759
|
console.error(profile);
|
|
@@ -1837,7 +1874,7 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
|
|
|
1837
1874
|
console.error(`\n${fmt.info(`${mode} → ${dirLabel}`)}`);
|
|
1838
1875
|
const parts = [`${blockedDirs.length} blocked`, `${ignoredPaths.length} hidden`];
|
|
1839
1876
|
if (readOnly.size > 0) parts.push(`${readOnly.size} read-only`);
|
|
1840
|
-
const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
|
|
1877
|
+
const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length - PROTECTED_HOME_DOTFILES.length;
|
|
1841
1878
|
if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
|
|
1842
1879
|
console.error(fmt.detail(parts.join(" · ")));
|
|
1843
1880
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bx-mac",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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"
|