bx-mac 1.3.0 โ 1.5.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.
- package/README.md +21 -2
- package/dist/bx.js +107 -23
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -234,21 +234,32 @@ Unified sandbox rules for your home directory. Paths relative to `$HOME`. Each l
|
|
|
234
234
|
.kube
|
|
235
235
|
.config/gcloud
|
|
236
236
|
|
|
237
|
-
# Allow read-write access to extra
|
|
237
|
+
# Allow read-write access to extra paths
|
|
238
238
|
rw:work/bin
|
|
239
239
|
rw:shared/libs
|
|
240
|
+
rw:~/projects/* # globs supported, ~ is expanded
|
|
240
241
|
|
|
241
242
|
# Allow read-only access (can read but not modify)
|
|
242
243
|
ro:reference/docs
|
|
243
244
|
ro:shared/toolchain
|
|
245
|
+
ro:.npmrc # files work too; overrides built-in block
|
|
244
246
|
```
|
|
245
247
|
|
|
246
|
-
|
|
248
|
+
Plain deny rules have two scopes:
|
|
249
|
+
|
|
250
|
+
- **At `$HOME` top level only** (no recursive `**` walk). `secrets/` matches `~/secrets`, not `~/nested/secrets`; `.config/gcloud` matches as a literal.
|
|
251
|
+
- **Recursively inside each workdir** - same semantics as a project-level `.bxignore`, so `secrets/` and `*.pem` apply across every project you open. The recursive scan skips `node_modules`, `.git`, caches, `DerivedData`, `Pods`, and similar subtrees for speed.
|
|
252
|
+
|
|
253
|
+
Deny rules are applied **in addition** to the built-in protected lists. `rw:` and `ro:` entries however **override** them - `ro:.npmrc` exposes the otherwise-blocked `~/.npmrc` read-only, `rw:.aws` opens the AWS credentials directory completely. Files (not just directories) are accepted, `~/...` is expanded, and globs (`*`, `**`) are matched against `$HOME`. Use override entries deliberately - you are weakening the default protection. `bx` prints a warning on stderr when an override exposes a built-in protected path.
|
|
254
|
+
|
|
255
|
+
Built-in protected lists:
|
|
247
256
|
|
|
248
257
|
> ๐ **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
|
|
249
258
|
>
|
|
250
259
|
> ๐๏ธ **Library (opinionated):** `Accounts` `Calendars` `Contacts` `Cookies` `Finance` `Mail` `Messages` `Mobile Documents` `Photos` `Safari` and [others (see full list)](src/profile.ts) โ plus containers of password managers & finance apps
|
|
251
260
|
|
|
261
|
+
**Limitation:** Overrides only work on whole protected paths. `rw:.aws/profile.json` does not selectively unblock a file inside `~/.aws` - the parent deny still wins (Apple SBPL: deny beats allow). Use `rw:.aws` to open the entire directory.
|
|
262
|
+
|
|
252
263
|
### `<project>/.bxignore`
|
|
253
264
|
|
|
254
265
|
Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
|
|
@@ -282,6 +293,13 @@ my-project/deploy/.bxignore # deployment credentials
|
|
|
282
293
|
|
|
283
294
|
Each `.bxignore` resolves its patterns relative to its own directory.
|
|
284
295
|
|
|
296
|
+
Project `.bxignore` also accepts `ro:` entries to make paths within the workdir read-only (write-protect generated files, vendored libraries, etc.). `rw:` is silently ignored here - the workdir is already read-write by default.
|
|
297
|
+
|
|
298
|
+
```gitignore
|
|
299
|
+
ro:vendor/
|
|
300
|
+
ro:generated/schema.ts
|
|
301
|
+
```
|
|
302
|
+
|
|
285
303
|
### Self-protecting directories
|
|
286
304
|
|
|
287
305
|
You can make any directory protect itself โ no global configuration needed. There are two ways:
|
|
@@ -397,6 +415,7 @@ These are great when available, but they only protect within their own tool. bx
|
|
|
397
415
|
## ๐ Alternatives
|
|
398
416
|
|
|
399
417
|
- [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.
|
|
418
|
+
- [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.
|
|
400
419
|
- **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
420
|
- **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.
|
|
402
421
|
|
package/dist/bx.js
CHANGED
|
@@ -1045,6 +1045,50 @@ function toGlobPattern(line) {
|
|
|
1045
1045
|
function resolveGlobMatches(pattern, baseDir) {
|
|
1046
1046
|
return globSync(toGlobPattern(pattern), { cwd: baseDir }).map((match) => resolve(baseDir, match));
|
|
1047
1047
|
}
|
|
1048
|
+
const SCAN_EXCLUDE_NAMES = new Set([
|
|
1049
|
+
"Library",
|
|
1050
|
+
"Applications",
|
|
1051
|
+
"node_modules",
|
|
1052
|
+
".git",
|
|
1053
|
+
".Trash",
|
|
1054
|
+
".cache",
|
|
1055
|
+
".npm",
|
|
1056
|
+
".pnpm-store",
|
|
1057
|
+
".yarn",
|
|
1058
|
+
".cargo",
|
|
1059
|
+
".rustup",
|
|
1060
|
+
".gradle",
|
|
1061
|
+
".gem",
|
|
1062
|
+
".m2",
|
|
1063
|
+
".nvm",
|
|
1064
|
+
".bun",
|
|
1065
|
+
".deno",
|
|
1066
|
+
"DerivedData",
|
|
1067
|
+
"Pods"
|
|
1068
|
+
]);
|
|
1069
|
+
function scanExcludeFilter(entry) {
|
|
1070
|
+
const name = typeof entry === "string" ? entry.split("/").pop() ?? "" : entry?.name ?? "";
|
|
1071
|
+
return SCAN_EXCLUDE_NAMES.has(name);
|
|
1072
|
+
}
|
|
1073
|
+
function resolveGlobMatchesBatch(patterns, baseDir) {
|
|
1074
|
+
if (patterns.length === 0) return [];
|
|
1075
|
+
return globSync(patterns.map(toGlobPattern), {
|
|
1076
|
+
cwd: baseDir,
|
|
1077
|
+
exclude: scanExcludeFilter
|
|
1078
|
+
}).map((match) => resolve(baseDir, match));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Resolve patterns against `baseDir` at top level only (no recursive `**`
|
|
1082
|
+
* expansion). Trailing slashes are stripped; leading slashes are stripped
|
|
1083
|
+
* (already anchored). Used for `~/.bxignore` in $HOME to keep the lookup
|
|
1084
|
+
* cheap while still matching literal/glob paths at the home root.
|
|
1085
|
+
*/
|
|
1086
|
+
function resolveTopLevelMatches(patterns, baseDir) {
|
|
1087
|
+
if (patterns.length === 0) return [];
|
|
1088
|
+
const cleaned = patterns.map((p) => p.startsWith("/") ? p.slice(1) : p).map((p) => p.endsWith("/") ? p.slice(0, -1) : p).filter((p) => p.length > 0);
|
|
1089
|
+
if (cleaned.length === 0) return [];
|
|
1090
|
+
return globSync(cleaned, { cwd: baseDir }).map((m) => resolve(baseDir, m));
|
|
1091
|
+
}
|
|
1048
1092
|
/**
|
|
1049
1093
|
* A directory is self-protected if it contains a `.bxprotect` file
|
|
1050
1094
|
* or a `.bxignore` with a bare `/` entry. Self-protected directories
|
|
@@ -1055,19 +1099,27 @@ function isSelfProtected(dir) {
|
|
|
1055
1099
|
if (existsSync(join(dir, ".bxprotect"))) return true;
|
|
1056
1100
|
return parseLines(join(dir, ".bxignore")).some((l) => l === "/" || l === ".");
|
|
1057
1101
|
}
|
|
1058
|
-
function applyIgnoreFile(filePath, baseDir, ignored) {
|
|
1102
|
+
function applyIgnoreFile(filePath, baseDir, ignored, readOnly) {
|
|
1059
1103
|
for (const line of parseLines(filePath)) {
|
|
1060
1104
|
if (line === "/" || line === ".") continue;
|
|
1105
|
+
const accessMatch = line.match(ACCESS_PREFIX_RE);
|
|
1106
|
+
if (accessMatch) {
|
|
1107
|
+
if (!readOnly) continue;
|
|
1108
|
+
const [, prefix, rawPath] = accessMatch;
|
|
1109
|
+
if (prefix.toUpperCase() !== "RO") continue;
|
|
1110
|
+
for (const m of resolveGlobMatches(rawPath.trim(), baseDir)) readOnly.add(realpathSafe(m));
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1061
1113
|
ignored.push(...resolveGlobMatches(line, baseDir));
|
|
1062
1114
|
}
|
|
1063
1115
|
}
|
|
1064
|
-
function collectIgnoreFilesRecursive(dir, ignored) {
|
|
1116
|
+
function collectIgnoreFilesRecursive(dir, ignored, readOnly) {
|
|
1065
1117
|
if (isSelfProtected(dir)) {
|
|
1066
1118
|
ignored.push(dir);
|
|
1067
1119
|
return;
|
|
1068
1120
|
}
|
|
1069
1121
|
const ignoreFile = join(dir, ".bxignore");
|
|
1070
|
-
if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
|
|
1122
|
+
if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored, readOnly);
|
|
1071
1123
|
let entries;
|
|
1072
1124
|
try {
|
|
1073
1125
|
entries = readdirSync(dir);
|
|
@@ -1078,11 +1130,29 @@ function collectIgnoreFilesRecursive(dir, ignored) {
|
|
|
1078
1130
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
1079
1131
|
const fullPath = join(dir, name);
|
|
1080
1132
|
try {
|
|
1081
|
-
if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
|
|
1133
|
+
if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored, readOnly);
|
|
1082
1134
|
} catch {}
|
|
1083
1135
|
}
|
|
1084
1136
|
}
|
|
1085
1137
|
const ACCESS_PREFIX_RE = /^(RW|RO):(.+)$/i;
|
|
1138
|
+
function expandHomePath(home, raw) {
|
|
1139
|
+
const trimmed = raw.trim();
|
|
1140
|
+
if (trimmed === "~") return home;
|
|
1141
|
+
if (trimmed.startsWith("~/")) return join(home, trimmed.slice(2));
|
|
1142
|
+
return resolve(home, trimmed);
|
|
1143
|
+
}
|
|
1144
|
+
function realpathSafe(p) {
|
|
1145
|
+
try {
|
|
1146
|
+
return realpathSync(p);
|
|
1147
|
+
} catch {
|
|
1148
|
+
return p;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
function resolveAccessTargets(home, raw) {
|
|
1152
|
+
const expanded = expandHomePath(home, raw);
|
|
1153
|
+
if (existsSync(expanded)) return [realpathSafe(expanded)];
|
|
1154
|
+
return globSync(raw.trim().replace(/^~\//, ""), { cwd: home }).map((m) => realpathSafe(join(home, m))).filter((p) => existsSync(p));
|
|
1155
|
+
}
|
|
1086
1156
|
function parseHomeConfig(home, workDirs) {
|
|
1087
1157
|
const allowed = new Set(workDirs);
|
|
1088
1158
|
const readOnly = /* @__PURE__ */ new Set();
|
|
@@ -1090,10 +1160,10 @@ function parseHomeConfig(home, workDirs) {
|
|
|
1090
1160
|
const match = line.match(ACCESS_PREFIX_RE);
|
|
1091
1161
|
if (!match) continue;
|
|
1092
1162
|
const [, prefix, rawPath] = match;
|
|
1093
|
-
const
|
|
1094
|
-
if (
|
|
1095
|
-
|
|
1096
|
-
|
|
1163
|
+
const targets = resolveAccessTargets(home, rawPath);
|
|
1164
|
+
if (targets.length === 0) continue;
|
|
1165
|
+
const target = prefix.toUpperCase() === "RW" ? allowed : readOnly;
|
|
1166
|
+
for (const t of targets) target.add(t);
|
|
1097
1167
|
}
|
|
1098
1168
|
return {
|
|
1099
1169
|
allowed,
|
|
@@ -1143,22 +1213,26 @@ function collectProtectedContainers(home) {
|
|
|
1143
1213
|
}
|
|
1144
1214
|
return matched;
|
|
1145
1215
|
}
|
|
1146
|
-
function
|
|
1147
|
-
return
|
|
1216
|
+
function isOverridden(path, overrides) {
|
|
1217
|
+
return overrides.has(path) || overrides.has(realpathSafe(path));
|
|
1148
1218
|
}
|
|
1149
|
-
function
|
|
1219
|
+
function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
|
|
1220
|
+
return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !isOverridden(p, overrides));
|
|
1221
|
+
}
|
|
1222
|
+
function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set(), readOnly) {
|
|
1150
1223
|
const ignored = [
|
|
1151
1224
|
...PROTECTED_DOTDIRS.map((d) => join(home, d)),
|
|
1152
1225
|
...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
|
|
1153
1226
|
...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
|
|
1154
1227
|
...new Set(collectProtectedContainers(home))
|
|
1155
|
-
];
|
|
1228
|
+
].filter((p) => !isOverridden(p, overrides));
|
|
1156
1229
|
const globalIgnore = join(home, ".bxignore");
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1230
|
+
const globalDenyLines = existsSync(globalIgnore) ? parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l)) : [];
|
|
1231
|
+
for (const m of resolveTopLevelMatches(globalDenyLines, home)) if (!isOverridden(m, overrides)) ignored.push(m);
|
|
1232
|
+
for (const workDir of workDirs) {
|
|
1233
|
+
for (const m of resolveGlobMatchesBatch(globalDenyLines, workDir)) if (!isOverridden(m, overrides)) ignored.push(m);
|
|
1234
|
+
collectIgnoreFilesRecursive(workDir, ignored, readOnly);
|
|
1160
1235
|
}
|
|
1161
|
-
for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
|
|
1162
1236
|
return ignored;
|
|
1163
1237
|
}
|
|
1164
1238
|
function sbplEscape(path) {
|
|
@@ -1199,7 +1273,7 @@ function collectSystemDenyPaths(home) {
|
|
|
1199
1273
|
function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "", readOnlyFiles = []) {
|
|
1200
1274
|
const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
|
|
1201
1275
|
const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
|
|
1202
|
-
const readOnlyRules = sbplDenyBlock("Read-only
|
|
1276
|
+
const readOnlyRules = sbplDenyBlock("Read-only paths", "file-write*", readOnlyDirs.map(sbplPathRule));
|
|
1203
1277
|
const readOnlyFileRules = sbplDenyBlock("Read-only home dotfiles (shell init - write-protected against injection)", "file-write*", readOnlyFiles.map(sbplLiteral));
|
|
1204
1278
|
const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
|
|
1205
1279
|
return `; Auto-generated sandbox profile
|
|
@@ -1623,8 +1697,8 @@ Configuration:
|
|
|
1623
1697
|
built-in apps (code, xcode) can be overridden
|
|
1624
1698
|
~/.bxignore sandbox rules (one per line):
|
|
1625
1699
|
path block access (deny)
|
|
1626
|
-
rw:path allow read-write access
|
|
1627
|
-
ro:path allow read-only access
|
|
1700
|
+
rw:path allow read-write access (overrides built-in protection)
|
|
1701
|
+
ro:path allow read-only access (overrides built-in protection)
|
|
1628
1702
|
<workdir>/.bxignore blocked paths in project (.gitignore-style matching)
|
|
1629
1703
|
/ or . self-protect: block entire directory
|
|
1630
1704
|
<dir>/.bxprotect marker file: block the containing directory
|
|
@@ -1699,7 +1773,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
|
|
|
1699
1773
|
}
|
|
1700
1774
|
//#endregion
|
|
1701
1775
|
//#region src/index.ts
|
|
1702
|
-
const VERSION = "1.
|
|
1776
|
+
const VERSION = "1.5.1";
|
|
1703
1777
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1704
1778
|
if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
|
|
1705
1779
|
console.log(`bx ${VERSION}`);
|
|
@@ -1748,9 +1822,11 @@ async function main() {
|
|
|
1748
1822
|
const profileSandbox = vscodeUserFlag !== false ? vscodeUserFlag : app?.profile ?? false;
|
|
1749
1823
|
if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
|
|
1750
1824
|
const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
const
|
|
1825
|
+
const allAccessible = new Set([...allowed, ...readOnly]);
|
|
1826
|
+
warnDangerousOverrides(HOME, allAccessible);
|
|
1827
|
+
const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
|
|
1828
|
+
const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible, readOnly);
|
|
1829
|
+
const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
|
|
1754
1830
|
printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
|
|
1755
1831
|
const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
|
|
1756
1832
|
if (verbose) {
|
|
@@ -1877,6 +1953,14 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
|
|
|
1877
1953
|
if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
|
|
1878
1954
|
console.error(fmt.detail(parts.join(" ยท ")));
|
|
1879
1955
|
}
|
|
1956
|
+
function warnDangerousOverrides(home, accessible) {
|
|
1957
|
+
const hits = [
|
|
1958
|
+
...PROTECTED_DOTDIRS.map((d) => join(home, d)),
|
|
1959
|
+
...PROTECTED_HOME_DOTFILES.map((f) => join(home, f)),
|
|
1960
|
+
...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d))
|
|
1961
|
+
].filter((p) => accessible.has(p));
|
|
1962
|
+
for (const p of hits) console.error(fmt.detail(`warning: ~/.bxignore override exposes built-in protected path ${p}`));
|
|
1963
|
+
}
|
|
1880
1964
|
function printLaunchDetails(cmd, cwd) {
|
|
1881
1965
|
const quote = (a) => JSON.stringify(a);
|
|
1882
1966
|
console.error(fmt.detail(`bin: ${cmd.bin}`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bx-mac",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Sandbox any macOS app โ only your project directory stays accessible",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"darwin"
|
|
48
48
|
],
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@types/node": "^25.
|
|
51
|
-
"rolldown": "^1.0.0-rc.
|
|
50
|
+
"@types/node": "^25.6.0",
|
|
51
|
+
"rolldown": "^1.0.0-rc.16",
|
|
52
52
|
"smol-toml": "^1.6.1",
|
|
53
|
-
"vitest": "^4.1.
|
|
53
|
+
"vitest": "^4.1.4"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"node": ">=22"
|