bx-mac 0.9.0 → 0.11.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.
Files changed (3) hide show
  1. package/README.md +62 -8
  2. package/dist/bx.js +324 -170
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -134,26 +134,27 @@ On normal runs, bx also prints a short policy summary (number of workdirs, block
134
134
 
135
135
  ### `~/.bxconfig.toml`
136
136
 
137
- App definitions in TOML format. Each `[apps.<name>]` section becomes a CLI mode — use it as `bx <name> [workdir...]`. Built-in apps (`code`, `xcode`) are always available and can be overridden.
137
+ App definitions in TOML format. Each `[<name>]` section becomes a CLI mode — use it as `bx <name> [workdir...]`. Built-in apps (`code`, `xcode`) are always available and can be overridden.
138
138
 
139
139
  ```toml
140
140
  # Add Cursor (auto-discovered via macOS Spotlight)
141
- [apps.cursor]
141
+ [cursor]
142
142
  bundle = "com.todesktop.230313mzl4w4u92"
143
143
  binary = "Contents/MacOS/Cursor"
144
144
  args = ["--no-sandbox"]
145
145
 
146
146
  # Add Zed (explicit path, no discovery)
147
- [apps.zed]
147
+ [zed]
148
148
  path = "/Applications/Zed.app/Contents/MacOS/zed"
149
149
 
150
150
  # Override built-in VSCode path
151
- [apps.code]
151
+ [code]
152
152
  path = "/usr/local/bin/code"
153
153
  ```
154
154
 
155
155
  | Field | Description |
156
156
  | --- | --- |
157
+ | `mode` | Inherit from another app (e.g. `"code"`, `"cursor"`) — only `workdirs` / overrides needed |
157
158
  | `bundle` | macOS bundle identifier — used with `mdfind` to find the app automatically |
158
159
  | `binary` | Relative path to the executable inside the `.app` bundle |
159
160
  | `path` | Absolute path to the executable **or** `.app` bundle (highest priority, skips discovery) |
@@ -166,10 +167,26 @@ path = "/usr/local/bin/code"
166
167
 
167
168
  `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.
168
169
 
169
- **Preconfigured workdirs** let you define your usual environment per app:
170
+ **Workdir shortcuts with `mode`** let you create named entries that inherit everything from an existing app — just set `mode` and `workdirs`:
170
171
 
171
172
  ```toml
172
- [apps.code]
173
+ # "bx myproject" opens VSCode with these directories
174
+ [myproject]
175
+ mode = "code"
176
+ workdirs = ["~/work/my-project", "~/work/shared-lib"]
177
+
178
+ # "bx ios" opens Xcode with this directory
179
+ [ios]
180
+ mode = "xcode"
181
+ workdirs = ["~/work/my-ios-app"]
182
+ ```
183
+
184
+ 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`).
185
+
186
+ **Preconfigured workdirs** also work directly on app definitions:
187
+
188
+ ```toml
189
+ [code]
173
190
  workdirs = ["~/work/my-project", "~/work/shared-lib"]
174
191
  ```
175
192
 
@@ -201,7 +218,7 @@ ro:shared/toolchain
201
218
 
202
219
  Deny rules are applied **in addition** to the built-in protected list:
203
220
 
204
- > 🔒 `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
221
+ > 🔒 `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
205
222
 
206
223
  ### `<project>/.bxignore`
207
224
 
@@ -236,6 +253,33 @@ my-project/deploy/.bxignore # deployment credentials
236
253
 
237
254
  Each `.bxignore` resolves its patterns relative to its own directory.
238
255
 
256
+ ### Self-protecting directories
257
+
258
+ You can make any directory protect itself — no global configuration needed. There are two ways:
259
+
260
+ **Option 1: `.bxignore` with `/` or `.`**
261
+
262
+ Create a `.bxignore` file containing a bare `/` or `.`:
263
+
264
+ ```gitignore
265
+ .
266
+ ```
267
+
268
+ This blocks the entire directory and everything inside it. You can combine it with other patterns (they become redundant since the whole directory is blocked).
269
+
270
+ **Option 2: `.bxprotect` marker file**
271
+
272
+ Create an empty `.bxprotect` file:
273
+
274
+ ```bash
275
+ touch ~/work/secret-project/.bxprotect
276
+ ```
277
+
278
+ Both methods have the same effect:
279
+
280
+ - **Inside a workdir:** If a subdirectory is self-protected, it is completely blocked (deny) and bx does not recurse into it.
281
+ - **As a workdir:** If you try to open a self-protected directory with `bx`, it refuses to launch with a clear error message.
282
+
239
283
  ## 🔧 How it works
240
284
 
241
285
  bx generates a macOS sandbox profile at launch time:
@@ -285,7 +329,7 @@ bx code ~/work/project-a ~/work/project-b
285
329
  Or preconfigure them in `~/.bxconfig.toml`:
286
330
 
287
331
  ```toml
288
- [apps.code]
332
+ [code]
289
333
  workdirs = ["~/work/project-a", "~/work/project-b"]
290
334
  ```
291
335
 
@@ -309,6 +353,16 @@ cat ./src/index.ts # ✅ Works!
309
353
  - **SQLite warnings:** `state.vscdb` errors may appear in logs — extensions still work
310
354
  - **`sandbox-exec` is undocumented:** Apple could change behavior with OS updates
311
355
 
356
+ ## 🤖 Built-in sandboxing in AI tools
357
+
358
+ Some AI coding tools ship with their own sandboxing. bx complements these by providing a **uniform, tool-independent** layer that works across all applications — including editors, shells, and custom commands:
359
+
360
+ - [Claude Code](https://code.claude.com/docs/en/sandboxing) — built-in sandbox for file and command restrictions
361
+ - [Gemini CLI](https://geminicli.com/docs/cli/sandbox/) — sandbox mode for file system access control
362
+ - [OpenAI Codex](https://developers.openai.com/codex/concepts/sandboxing) — containerized sandboxing for code execution
363
+
364
+ 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.
365
+
312
366
  ## 📄 License
313
367
 
314
368
  MIT — see [LICENSE](LICENSE).
package/dist/bx.js CHANGED
@@ -796,7 +796,8 @@ const BUILTIN_APPS = {
796
796
  xcode: {
797
797
  bundle: "com.apple.dt.Xcode",
798
798
  binary: "Contents/MacOS/Xcode",
799
- fallback: "/Applications/Xcode.app/Contents/MacOS/Xcode"
799
+ fallback: "/Applications/Xcode.app/Contents/MacOS/Xcode",
800
+ passWorkdirs: false
800
801
  }
801
802
  };
802
803
  /** Shell-only built-in modes that are not app definitions */
@@ -805,6 +806,18 @@ const BUILTIN_MODES = [
805
806
  "claude",
806
807
  "exec"
807
808
  ];
809
+ function parseAppDef(def) {
810
+ return {
811
+ mode: typeof def.mode === "string" ? def.mode : void 0,
812
+ bundle: typeof def.bundle === "string" ? def.bundle : void 0,
813
+ binary: typeof def.binary === "string" ? def.binary : void 0,
814
+ path: typeof def.path === "string" ? def.path : void 0,
815
+ 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 : void 0,
818
+ workdirs: Array.isArray(def.workdirs) ? def.workdirs.filter((a) => typeof a === "string") : void 0
819
+ };
820
+ }
808
821
  /**
809
822
  * Load and parse ~/.bxconfig.toml. Returns empty apps if file missing or invalid.
810
823
  */
@@ -814,15 +827,26 @@ function loadConfig(home) {
814
827
  try {
815
828
  const doc = parse(readFileSync(configPath, "utf-8"));
816
829
  const apps = {};
817
- if (doc.apps && typeof doc.apps === "object") for (const [name, def] of Object.entries(doc.apps)) apps[name] = {
818
- bundle: typeof def.bundle === "string" ? def.bundle : void 0,
819
- binary: typeof def.binary === "string" ? def.binary : void 0,
820
- path: typeof def.path === "string" ? def.path : void 0,
821
- fallback: typeof def.fallback === "string" ? def.fallback : void 0,
822
- args: Array.isArray(def.args) ? def.args.filter((a) => typeof a === "string") : void 0,
823
- passWorkdirs: typeof def.passWorkdirs === "boolean" ? def.passWorkdirs : void 0,
824
- workdirs: Array.isArray(def.workdirs) ? def.workdirs.filter((a) => typeof a === "string") : void 0
825
- };
830
+ const sections = {};
831
+ const APP_FIELDS = new Set([
832
+ "mode",
833
+ "bundle",
834
+ "binary",
835
+ "path",
836
+ "fallback",
837
+ "args",
838
+ "passWorkdirs",
839
+ "workdirs"
840
+ ]);
841
+ for (const [key, val] of Object.entries(doc)) {
842
+ if (key === "apps") continue;
843
+ if (val && typeof val === "object" && !Array.isArray(val)) {
844
+ const obj = val;
845
+ if (Object.keys(obj).some((k) => APP_FIELDS.has(k))) sections[key] = obj;
846
+ }
847
+ }
848
+ if (doc.apps && typeof doc.apps === "object") Object.assign(sections, doc.apps);
849
+ for (const [name, def] of Object.entries(sections)) apps[name] = parseAppDef(def);
826
850
  return { apps };
827
851
  } catch (err) {
828
852
  console.error(`\n${fmt.warn(`failed to parse ${configPath}: ${err instanceof Error ? err.message : err}`)}`);
@@ -831,6 +855,8 @@ function loadConfig(home) {
831
855
  }
832
856
  /**
833
857
  * Merge built-in apps with user config (config wins on conflict).
858
+ * Resolves `mode` references: an app with `mode = "code"` inherits
859
+ * all fields from the "code" definition, then overlays its own fields.
834
860
  */
835
861
  function getAvailableApps(config) {
836
862
  const merged = {};
@@ -839,9 +865,34 @@ function getAvailableApps(config) {
839
865
  ...merged[name],
840
866
  ...stripUndefined(def)
841
867
  };
842
- else merged[name] = def;
868
+ else merged[name] = def.mode || def.bundle || def.path || def.fallback ? def : {
869
+ ...def,
870
+ mode: "code"
871
+ };
872
+ for (const name of Object.keys(merged)) merged[name] = resolveModeChain(name, merged);
843
873
  return merged;
844
874
  }
875
+ function resolveModeChain(name, apps, seen = /* @__PURE__ */ new Set()) {
876
+ const def = apps[name];
877
+ if (!def.mode) return def;
878
+ if (seen.has(name)) {
879
+ console.error(`\n${fmt.warn(`circular mode reference: ${[...seen, name].join(" → ")}`)}`);
880
+ const { mode: _, ...rest } = def;
881
+ return rest;
882
+ }
883
+ seen.add(name);
884
+ if (!apps[def.mode]) {
885
+ console.error(`\n${fmt.warn(`mode "${def.mode}" not found for app "${name}"`)}`);
886
+ const { mode: _, ...rest } = def;
887
+ return rest;
888
+ }
889
+ const resolved = resolveModeChain(def.mode, apps, seen);
890
+ const { mode: _, ...ownFields } = def;
891
+ return {
892
+ ...resolved,
893
+ ...stripUndefined(ownFields)
894
+ };
895
+ }
845
896
  function stripUndefined(obj) {
846
897
  const result = {};
847
898
  for (const [k, v] of Object.entries(obj)) if (v !== void 0) result[k] = v;
@@ -876,143 +927,8 @@ function resolveAppPath(app) {
876
927
  return null;
877
928
  }
878
929
  //#endregion
879
- //#region src/guards.ts
880
- /**
881
- * Abort if we're already inside a bx sandbox (env var set by us).
882
- */
883
- function checkOwnSandbox() {
884
- if (process$1.env.CODEBOX_SANDBOX === "1") {
885
- console.error(`\n${fmt.error("already running inside a bx sandbox")}`);
886
- console.error(fmt.detail("nesting sandbox-exec causes silent failures\n"));
887
- process$1.exit(1);
888
- }
889
- }
890
- /**
891
- * Warn if launched from inside a VSCode terminal.
892
- */
893
- function checkVSCodeTerminal() {
894
- if (process$1.env.VSCODE_PID) {
895
- console.error(`\n${fmt.warn("running from inside a VSCode terminal")}`);
896
- console.error(fmt.detail("this will launch a *new* instance in a sandbox"));
897
- console.error(fmt.detail("the current VSCode instance will NOT be sandboxed"));
898
- }
899
- }
900
- /**
901
- * Abort if any workdir IS $HOME or is not inside $HOME.
902
- */
903
- function checkWorkDirs(workDirs, home) {
904
- for (const dir of workDirs) {
905
- if (dir === home) {
906
- console.error(`\n${fmt.error("working directory cannot be $HOME itself")}`);
907
- console.error(fmt.detail("sandboxing your entire home directory is not supported\n"));
908
- process$1.exit(1);
909
- }
910
- if (!dir.startsWith(home + "/")) {
911
- console.error(`\n${fmt.error(`working directory is outside $HOME: ${dir}`)}`);
912
- console.error(fmt.detail("only directories inside $HOME are supported\n"));
913
- process$1.exit(1);
914
- }
915
- }
916
- }
917
- /**
918
- * Warn if the target app is already running — the new workspace will open
919
- * in the existing (unsandboxed) instance, bypassing our sandbox profile.
920
- */
921
- async function checkAppAlreadyRunning(mode, apps) {
922
- if (BUILTIN_MODES.includes(mode)) return;
923
- const app = apps[mode];
924
- if (!app?.bundle) return;
925
- let running = false;
926
- try {
927
- running = execFileSync("lsappinfo", ["list"], {
928
- encoding: "utf-8",
929
- timeout: 3e3
930
- }).includes(`bundleID="${app.bundle}"`);
931
- } catch {
932
- return;
933
- }
934
- if (!running) return;
935
- console.error(`\n${fmt.warn(`"${mode}" is already running`)}`);
936
- console.error(fmt.detail("the workspace will open in the EXISTING instance — sandbox will NOT apply"));
937
- if (mode === "code") console.error(fmt.detail("quit the app first, or use --profile-sandbox for an isolated instance"));
938
- else console.error(fmt.detail("quit the app first to ensure sandbox protection"));
939
- const rl = createInterface({
940
- input: process$1.stdin,
941
- output: process$1.stderr
942
- });
943
- const answer = await new Promise((res) => {
944
- rl.question(` continue without sandbox? [y/N] `, res);
945
- });
946
- rl.close();
947
- if (!answer.match(/^y(es)?$/i)) process$1.exit(0);
948
- }
949
- /**
950
- * Detect if we're inside an unknown sandbox by probing well-known
951
- * directories that exist on every Mac but would be blocked.
952
- */
953
- function checkExternalSandbox() {
954
- for (const dir of [
955
- "Documents",
956
- "Desktop",
957
- "Downloads"
958
- ]) {
959
- const target = join(process$1.env.HOME, dir);
960
- try {
961
- accessSync(target, constants.R_OK);
962
- } catch (e) {
963
- if (e.code === "EPERM") {
964
- console.error(`\n${fmt.error("already running inside a sandbox")}`);
965
- console.error(fmt.detail("nesting sandbox-exec may cause silent failures\n"));
966
- process$1.exit(1);
967
- }
968
- }
969
- }
970
- }
971
- //#endregion
972
- //#region src/args.ts
973
- /**
974
- * Parse CLI arguments. `validModes` is the list of recognized mode names
975
- * (builtin modes + app names from config).
976
- */
977
- function parseArgs(validModes) {
978
- const rawArgs = process$1.argv.slice(2);
979
- const verbose = rawArgs.includes("--verbose");
980
- const dry = rawArgs.includes("--dry");
981
- const profileSandbox = rawArgs.includes("--profile-sandbox");
982
- const positional = rawArgs.filter((a) => !a.startsWith("--"));
983
- const doubleDashIdx = rawArgs.indexOf("--");
984
- const appArgs = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
985
- const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
986
- let mode = "code";
987
- let workArgs;
988
- let implicitWorkdirs = false;
989
- if (beforeDash.length > 0 && validModes.includes(beforeDash[0])) {
990
- mode = beforeDash[0];
991
- workArgs = beforeDash.slice(1);
992
- } else workArgs = beforeDash;
993
- if (workArgs.length === 0) {
994
- workArgs = ["."];
995
- implicitWorkdirs = true;
996
- }
997
- if (mode === "exec" && appArgs.length === 0) {
998
- console.error(`\n${fmt.error("exec mode requires a command after \"--\"")}`);
999
- console.error(fmt.detail("usage: bx exec [workdir...] -- command [args...]\n"));
1000
- process$1.exit(1);
1001
- }
1002
- return {
1003
- mode,
1004
- workArgs,
1005
- verbose,
1006
- dry,
1007
- profileSandbox,
1008
- appArgs,
1009
- implicit: implicitWorkdirs
1010
- };
1011
- }
1012
- //#endregion
1013
930
  //#region src/profile.ts
1014
931
  const PROTECTED_DOTDIRS = [
1015
- ".Trash",
1016
932
  ".ssh",
1017
933
  ".gnupg",
1018
934
  ".docker",
@@ -1021,6 +937,40 @@ const PROTECTED_DOTDIRS = [
1021
937
  ".gradle",
1022
938
  ".gem"
1023
939
  ];
940
+ const PROTECTED_LIBRARY_DIRS = [
941
+ "Accounts",
942
+ "Calendars",
943
+ "CallServices",
944
+ "CloudStorage",
945
+ "Contacts",
946
+ "Cookies",
947
+ "Finance",
948
+ "FinanceBackup",
949
+ "Google",
950
+ "HomeKit",
951
+ "IdentityServices",
952
+ "Mail",
953
+ "Messages",
954
+ "Mobile Documents",
955
+ "News",
956
+ "Passes",
957
+ "PersonalizationPortrait",
958
+ "Photos",
959
+ "Safari",
960
+ "SafariSafeBrowsing",
961
+ "Sharing",
962
+ "Suggestions",
963
+ "Thunderbird",
964
+ "WebKit",
965
+ "com.apple.appleaccountd",
966
+ "com.apple.iTunesCloud"
967
+ ];
968
+ const PROTECTED_CONTAINER_PATTERNS = [
969
+ "com.bitwarden.*",
970
+ "com.agilebits.*",
971
+ "com.1password.*",
972
+ "com.moneymoney-app.*"
973
+ ];
1024
974
  function parseLines(filePath) {
1025
975
  if (!existsSync(filePath)) return [];
1026
976
  return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
@@ -1041,10 +991,27 @@ function toGlobPattern(line) {
1041
991
  function resolveGlobMatches(pattern, baseDir) {
1042
992
  return globSync(toGlobPattern(pattern), { cwd: baseDir }).map((match) => resolve(baseDir, match));
1043
993
  }
994
+ /**
995
+ * A directory is self-protected if it contains a `.bxprotect` file
996
+ * or a `.bxignore` with a bare `/` entry. Self-protected directories
997
+ * are blocked entirely — they cannot be used as workdirs and are
998
+ * denied inside workdir trees.
999
+ */
1000
+ function isSelfProtected(dir) {
1001
+ if (existsSync(join(dir, ".bxprotect"))) return true;
1002
+ return parseLines(join(dir, ".bxignore")).some((l) => l === "/" || l === ".");
1003
+ }
1044
1004
  function applyIgnoreFile(filePath, baseDir, ignored) {
1045
- for (const line of parseLines(filePath)) ignored.push(...resolveGlobMatches(line, baseDir));
1005
+ for (const line of parseLines(filePath)) {
1006
+ if (line === "/" || line === ".") continue;
1007
+ ignored.push(...resolveGlobMatches(line, baseDir));
1008
+ }
1046
1009
  }
1047
1010
  function collectIgnoreFilesRecursive(dir, ignored) {
1011
+ if (isSelfProtected(dir)) {
1012
+ ignored.push(dir);
1013
+ return;
1014
+ }
1048
1015
  const ignoreFile = join(dir, ".bxignore");
1049
1016
  if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
1050
1017
  let entries;
@@ -1117,8 +1084,22 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
1117
1084
  }
1118
1085
  return blocked;
1119
1086
  }
1087
+ function collectProtectedContainers(home) {
1088
+ const containerDirs = ["Containers", "Group Containers"];
1089
+ const matched = [];
1090
+ for (const dir of containerDirs) {
1091
+ const base = join(home, "Library", dir);
1092
+ if (!existsSync(base)) continue;
1093
+ for (const pattern of PROTECTED_CONTAINER_PATTERNS) matched.push(...globSync(pattern, { cwd: base }).map((m) => join(base, m)));
1094
+ }
1095
+ return matched;
1096
+ }
1120
1097
  function collectIgnoredPaths(home, workDirs) {
1121
- const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
1098
+ const ignored = [
1099
+ ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1100
+ ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
1101
+ ...new Set(collectProtectedContainers(home))
1102
+ ];
1122
1103
  const globalIgnore = join(home, ".bxignore");
1123
1104
  if (existsSync(globalIgnore)) {
1124
1105
  const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
@@ -1144,29 +1125,183 @@ function sbplPathRule(path) {
1144
1125
  return isDir ? sbplSubpath(path) : sbplLiteral(path);
1145
1126
  }
1146
1127
  function sbplDenyBlock(comment, verb, rules) {
1147
- if (rules.length === 0) return "";
1148
- return `\n; ${comment}\n(deny ${verb}\n${rules.join("\n")}\n)\n`;
1128
+ const unique = [...new Set(rules)];
1129
+ if (unique.length === 0) return "";
1130
+ return `\n; ${comment}\n(deny ${verb}\n${unique.join("\n")}\n)\n`;
1149
1131
  }
1150
- function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = []) {
1132
+ function collectSystemDenyPaths(home) {
1133
+ const paths = [];
1134
+ if (existsSync("/Volumes")) paths.push("/Volumes");
1135
+ try {
1136
+ for (const name of readdirSync("/Users")) {
1137
+ const userDir = join("/Users", name);
1138
+ if (userDir === home || name === "Shared") continue;
1139
+ try {
1140
+ if (statSync(userDir).isDirectory()) paths.push(userDir);
1141
+ } catch {}
1142
+ }
1143
+ } catch {}
1144
+ return paths;
1145
+ }
1146
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1151
1147
  const blockedRules = sbplDenyBlock("Blocked directories (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplSubpath));
1152
1148
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1153
1149
  const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1150
+ const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
1154
1151
  return `; Auto-generated sandbox profile
1155
1152
  ; Working directories: ${workDirs.join(", ")}
1156
1153
 
1157
1154
  (version 1)
1158
1155
  (allow default)
1159
- ${blockedRules}${ignoredRules}${readOnlyRules}
1156
+ ${blockedRules}${ignoredRules}${readOnlyRules}${systemRules}
1160
1157
  `;
1161
1158
  }
1162
1159
  //#endregion
1160
+ //#region src/guards.ts
1161
+ /**
1162
+ * Abort if we're already inside a bx sandbox (env var set by us).
1163
+ */
1164
+ function checkOwnSandbox() {
1165
+ if (process$1.env.CODEBOX_SANDBOX === "1") {
1166
+ console.error(`\n${fmt.error("already running inside a bx sandbox")}`);
1167
+ console.error(fmt.detail("nesting sandbox-exec causes silent failures\n"));
1168
+ process$1.exit(1);
1169
+ }
1170
+ }
1171
+ /**
1172
+ * Warn if launched from inside a VSCode terminal.
1173
+ */
1174
+ function checkVSCodeTerminal() {
1175
+ if (process$1.env.VSCODE_PID) {
1176
+ console.error(`\n${fmt.warn("running from inside a VSCode terminal")}`);
1177
+ console.error(fmt.detail("this will launch a *new* instance in a sandbox"));
1178
+ console.error(fmt.detail("the current VSCode instance will NOT be sandboxed"));
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Abort if any workdir IS $HOME or is not inside $HOME.
1183
+ */
1184
+ function checkWorkDirs(workDirs, home) {
1185
+ for (const dir of workDirs) {
1186
+ if (dir === home) {
1187
+ console.error(`\n${fmt.error("working directory cannot be $HOME itself")}`);
1188
+ console.error(fmt.detail("sandboxing your entire home directory is not supported\n"));
1189
+ process$1.exit(1);
1190
+ }
1191
+ if (!dir.startsWith(home + "/")) {
1192
+ console.error(`\n${fmt.error(`working directory is outside $HOME: ${dir}`)}`);
1193
+ console.error(fmt.detail("only directories inside $HOME are supported\n"));
1194
+ process$1.exit(1);
1195
+ }
1196
+ if (isSelfProtected(dir)) {
1197
+ console.error(`\n${fmt.error(`working directory is self-protected: ${dir}`)}`);
1198
+ console.error(fmt.detail("remove .bxprotect or '/' from .bxignore to allow access\n"));
1199
+ process$1.exit(1);
1200
+ }
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Warn if the target app is already running — the new workspace will open
1205
+ * in the existing (unsandboxed) instance, bypassing our sandbox profile.
1206
+ */
1207
+ async function checkAppAlreadyRunning(mode, apps) {
1208
+ if (BUILTIN_MODES.includes(mode)) return;
1209
+ const app = apps[mode];
1210
+ if (!app?.bundle) return;
1211
+ let running = false;
1212
+ try {
1213
+ running = execFileSync("lsappinfo", ["list"], {
1214
+ encoding: "utf-8",
1215
+ timeout: 3e3
1216
+ }).includes(`bundleID="${app.bundle}"`);
1217
+ } catch {
1218
+ return;
1219
+ }
1220
+ if (!running) return;
1221
+ console.error(`\n${fmt.warn(`"${mode}" is already running`)}`);
1222
+ console.error(fmt.detail("the workspace will open in the EXISTING instance — sandbox will NOT apply"));
1223
+ if (mode === "code") console.error(fmt.detail("quit the app first, or use --profile-sandbox for an isolated instance"));
1224
+ else console.error(fmt.detail("quit the app first to ensure sandbox protection"));
1225
+ const rl = createInterface({
1226
+ input: process$1.stdin,
1227
+ output: process$1.stderr
1228
+ });
1229
+ const answer = await new Promise((res) => {
1230
+ rl.question(` continue without sandbox? [y/N] `, res);
1231
+ });
1232
+ rl.close();
1233
+ if (!answer.match(/^y(es)?$/i)) process$1.exit(0);
1234
+ }
1235
+ /**
1236
+ * Detect if we're inside an unknown sandbox by probing well-known
1237
+ * directories that exist on every Mac but would be blocked.
1238
+ */
1239
+ function checkExternalSandbox() {
1240
+ for (const dir of [
1241
+ "Documents",
1242
+ "Desktop",
1243
+ "Downloads"
1244
+ ]) {
1245
+ const target = join(process$1.env.HOME, dir);
1246
+ try {
1247
+ accessSync(target, constants.R_OK);
1248
+ } catch (e) {
1249
+ if (e.code === "EPERM") {
1250
+ console.error(`\n${fmt.error("already running inside a sandbox")}`);
1251
+ console.error(fmt.detail("nesting sandbox-exec may cause silent failures\n"));
1252
+ process$1.exit(1);
1253
+ }
1254
+ }
1255
+ }
1256
+ }
1257
+ //#endregion
1258
+ //#region src/args.ts
1259
+ /**
1260
+ * Parse CLI arguments. `validModes` is the list of recognized mode names
1261
+ * (builtin modes + app names from config).
1262
+ */
1263
+ function parseArgs(validModes) {
1264
+ const rawArgs = process$1.argv.slice(2);
1265
+ const verbose = rawArgs.includes("--verbose");
1266
+ const dry = rawArgs.includes("--dry");
1267
+ const profileSandbox = rawArgs.includes("--profile-sandbox");
1268
+ const positional = rawArgs.filter((a) => !a.startsWith("--"));
1269
+ const doubleDashIdx = rawArgs.indexOf("--");
1270
+ const appArgs = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
1271
+ const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
1272
+ let mode = "code";
1273
+ let workArgs;
1274
+ let implicitWorkdirs = false;
1275
+ if (beforeDash.length > 0 && validModes.includes(beforeDash[0])) {
1276
+ mode = beforeDash[0];
1277
+ workArgs = beforeDash.slice(1);
1278
+ } else workArgs = beforeDash;
1279
+ if (workArgs.length === 0) {
1280
+ workArgs = ["."];
1281
+ implicitWorkdirs = true;
1282
+ }
1283
+ if (mode === "exec" && appArgs.length === 0) {
1284
+ console.error(`\n${fmt.error("exec mode requires a command after \"--\"")}`);
1285
+ console.error(fmt.detail("usage: bx exec [workdir...] -- command [args...]\n"));
1286
+ process$1.exit(1);
1287
+ }
1288
+ return {
1289
+ mode,
1290
+ workArgs,
1291
+ verbose,
1292
+ dry,
1293
+ profileSandbox,
1294
+ appArgs,
1295
+ implicit: implicitWorkdirs
1296
+ };
1297
+ }
1298
+ //#endregion
1163
1299
  //#region src/modes.ts
1164
1300
  function isBuiltinMode(mode) {
1165
1301
  return BUILTIN_MODES.includes(mode);
1166
1302
  }
1167
- function shouldPassWorkdirs(app, mode) {
1168
- if (typeof app.passWorkdirs === "boolean") return app.passWorkdirs;
1169
- return mode !== "xcode";
1303
+ function shouldPassWorkdirs(app) {
1304
+ return app.passWorkdirs !== false;
1170
1305
  }
1171
1306
  function appBundleFromPath(path) {
1172
1307
  if (path.endsWith(".app")) return path;
@@ -1237,7 +1372,7 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1237
1372
  }
1238
1373
  if (app.args) args.push(...app.args);
1239
1374
  if (appArgs.length > 0) args.push(...appArgs);
1240
- if (shouldPassWorkdirs(app, mode)) args.push(...workDirs);
1375
+ if (shouldPassWorkdirs(app)) args.push(...workDirs);
1241
1376
  return {
1242
1377
  bin,
1243
1378
  args
@@ -1333,7 +1468,8 @@ Options:
1333
1468
 
1334
1469
  Configuration:
1335
1470
  ~/.bxconfig.toml app definitions (TOML):
1336
- [apps.name] add a new app
1471
+ [name] add a new app
1472
+ mode = "..." inherit from another app
1337
1473
  bundle = "..." macOS bundle ID (auto-discovery)
1338
1474
  binary = "..." relative path in .app bundle
1339
1475
  path = "..." explicit executable path
@@ -1345,6 +1481,8 @@ Configuration:
1345
1481
  rw:path allow read-write access
1346
1482
  ro:path allow read-only access
1347
1483
  <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
1484
+ / or . self-protect: block entire directory
1485
+ <dir>/.bxprotect marker file: block the containing directory
1348
1486
 
1349
1487
  https://github.com/holtwick/bx-mac`;
1350
1488
  //#endregion
@@ -1362,16 +1500,25 @@ function kindIcon(kind) {
1362
1500
  default: return `${RED}✖${RESET}`;
1363
1501
  }
1364
1502
  }
1365
- function insertPath(root, home, absPath, kind, isDir) {
1366
- const rel = absPath.startsWith(home + "/") ? absPath.slice(home.length + 1) : absPath;
1503
+ function insertPath(root, homeParts, absPath, kind, isDir) {
1504
+ const parts = absPath.split("/").filter(Boolean);
1367
1505
  let node = root;
1368
- for (const part of rel.split("/")) {
1506
+ for (const part of parts) {
1369
1507
  if (!node.children.has(part)) node.children.set(part, { children: /* @__PURE__ */ new Map() });
1370
1508
  node = node.children.get(part);
1371
1509
  }
1372
1510
  node.kind = kind;
1373
1511
  node.isDir = isDir;
1374
1512
  }
1513
+ /** Collapse the tree down to the interesting nodes, keeping only branches
1514
+ * that contain a leaf with a kind (blocked/ignored/workdir/read-only).
1515
+ * Intermediate directories on the home path are kept as navigation context. */
1516
+ function pruneTree(node, currentParts, homeParts, depth) {
1517
+ if (node.kind) return true;
1518
+ depth < homeParts.length && (currentParts[depth], homeParts[depth]);
1519
+ for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
1520
+ return node.children.size > 0;
1521
+ }
1375
1522
  function isDirectory(path) {
1376
1523
  try {
1377
1524
  return statSync(path).isDirectory();
@@ -1393,20 +1540,26 @@ function printNode(node, prefix) {
1393
1540
  if (child.children.size > 0) printNode(child, prefix + continuation);
1394
1541
  }
1395
1542
  }
1396
- function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs }) {
1543
+ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
1397
1544
  const root = { children: /* @__PURE__ */ new Map() };
1398
- for (const dir of blockedDirs) insertPath(root, home, dir, "blocked", true);
1399
- for (const path of ignoredPaths) insertPath(root, home, path, "ignored", isDirectory(path));
1400
- for (const dir of readOnlyDirs) insertPath(root, home, dir, "read-only", true);
1401
- for (const dir of workDirs) insertPath(root, home, dir, "workdir", true);
1402
- console.log(`\n${CYAN}~/${RESET}`);
1545
+ const homeParts = home.split("/").filter(Boolean);
1546
+ for (const dir of blockedDirs) insertPath(root, homeParts, dir, "blocked", true);
1547
+ for (const path of ignoredPaths) insertPath(root, homeParts, path, "ignored", isDirectory(path));
1548
+ for (const dir of readOnlyDirs) insertPath(root, homeParts, dir, "read-only", true);
1549
+ for (const dir of workDirs) insertPath(root, homeParts, dir, "workdir", true);
1550
+ for (const dir of systemDenyPaths) insertPath(root, homeParts, dir, "blocked", true);
1551
+ pruneTree(root, [], homeParts, 0);
1552
+ console.log(`\n${CYAN}/${RESET}`);
1403
1553
  printNode(root, "");
1404
1554
  console.log(`\n${RED}✖${RESET} = denied ${YELLOW}◉${RESET} = read-only ${GREEN}✔${RESET} = read-write\n`);
1405
1555
  }
1406
1556
  //#endregion
1557
+ //#region package.json
1558
+ var version = "0.11.0";
1559
+ //#endregion
1407
1560
  //#region src/index.ts
1408
1561
  const __dirname = dirname(fileURLToPath(import.meta.url));
1409
- const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
1562
+ const VERSION = version;
1410
1563
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1411
1564
  console.log(`bx ${VERSION}`);
1412
1565
  process$1.exit(0);
@@ -1430,7 +1583,7 @@ async function main() {
1430
1583
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
1431
1584
  console.error(fmt.detail(`Usage: bx ${mode} <workdir>`));
1432
1585
  console.error(fmt.detail(`Config: set default workdirs in ~/.bxconfig.toml:\n`));
1433
- console.error(fmt.detail(`[apps.${mode}]`));
1586
+ console.error(fmt.detail(`[${mode}]`));
1434
1587
  console.error(fmt.detail(`workdirs = ["~/work/my-project"]\n`));
1435
1588
  process$1.exit(1);
1436
1589
  }
@@ -1448,7 +1601,7 @@ async function main() {
1448
1601
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1449
1602
  const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
1450
1603
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1451
- const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly]);
1604
+ const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME);
1452
1605
  if (verbose) {
1453
1606
  console.error("\n--- Generated sandbox profile ---");
1454
1607
  console.error(profile);
@@ -1460,7 +1613,8 @@ async function main() {
1460
1613
  blockedDirs,
1461
1614
  ignoredPaths,
1462
1615
  readOnlyDirs: readOnly,
1463
- workDirs
1616
+ workDirs,
1617
+ systemDenyPaths: collectSystemDenyPaths(HOME)
1464
1618
  });
1465
1619
  process$1.exit(0);
1466
1620
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {