bx-mac 1.4.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.
Files changed (3) hide show
  1. package/README.md +19 -3
  2. package/dist/bx.js +100 -17
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -234,23 +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 directories
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
- `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.
248
+ Plain deny rules have two scopes:
247
249
 
248
- Deny rules are applied **in addition** to the built-in protected lists:
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:
249
256
 
250
257
  > ๐Ÿ”’ **Dotdirs:** `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
251
258
  >
252
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
253
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
+
254
263
  ### `<project>/.bxignore`
255
264
 
256
265
  Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
@@ -284,6 +293,13 @@ my-project/deploy/.bxignore # deployment credentials
284
293
 
285
294
  Each `.bxignore` resolves its patterns relative to its own directory.
286
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
+
287
303
  ### Self-protecting directories
288
304
 
289
305
  You can make any directory protect itself โ€” no global configuration needed. There are two ways:
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 absolute = resolve(home, rawPath.trim());
1094
- if (!existsSync(absolute)) continue;
1095
- if (prefix.toUpperCase() === "RW") allowed.add(absolute);
1096
- else readOnly.add(absolute);
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
  }
1216
+ function isOverridden(path, overrides) {
1217
+ return overrides.has(path) || overrides.has(realpathSafe(path));
1218
+ }
1146
1219
  function collectReadOnlyDotfiles(home, overrides = /* @__PURE__ */ new Set()) {
1147
- return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !overrides.has(p));
1220
+ return PROTECTED_HOME_DOTFILES_RO.map((f) => join(home, f)).filter((p) => !isOverridden(p, overrides));
1148
1221
  }
1149
- function collectIgnoredPaths(home, workDirs, overrides = /* @__PURE__ */ new Set()) {
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
- ].filter((p) => !overrides.has(p));
1228
+ ].filter((p) => !isOverridden(p, overrides));
1156
1229
  const globalIgnore = join(home, ".bxignore");
1157
- if (existsSync(globalIgnore)) {
1158
- const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
1159
- for (const line of denyLines) for (const m of resolveGlobMatches(line, home)) if (!overrides.has(m)) ignored.push(m);
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) {
@@ -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.4.0";
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}`);
@@ -1749,8 +1823,9 @@ async function main() {
1749
1823
  if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
1750
1824
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1751
1825
  const allAccessible = new Set([...allowed, ...readOnly]);
1826
+ warnDangerousOverrides(HOME, allAccessible);
1752
1827
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, allAccessible);
1753
- const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible);
1828
+ const ignoredPaths = collectIgnoredPaths(HOME, workDirs, allAccessible, readOnly);
1754
1829
  const readOnlyDotfiles = collectReadOnlyDotfiles(HOME, allAccessible);
1755
1830
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1756
1831
  const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME, readOnlyDotfiles);
@@ -1878,6 +1953,14 @@ function printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly)
1878
1953
  if (extraIgnored > 0) parts.push(`${extraIgnored} from .bxignore`);
1879
1954
  console.error(fmt.detail(parts.join(" ยท ")));
1880
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
+ }
1881
1964
  function printLaunchDetails(cmd, cwd) {
1882
1965
  const quote = (a) => JSON.stringify(a);
1883
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.4.0",
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.5.2",
51
- "rolldown": "^1.0.0-rc.13",
50
+ "@types/node": "^25.6.0",
51
+ "rolldown": "^1.0.0-rc.16",
52
52
  "smol-toml": "^1.6.1",
53
- "vitest": "^4.1.2"
53
+ "vitest": "^4.1.4"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=22"