bx-mac 0.9.0 → 0.10.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 +61 -7
  2. package/dist/bx.js +320 -168
  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
 
@@ -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,140 +927,6 @@ 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
932
  ".Trash",
@@ -1021,6 +938,40 @@ const PROTECTED_DOTDIRS = [
1021
938
  ".gradle",
1022
939
  ".gem"
1023
940
  ];
941
+ const PROTECTED_LIBRARY_DIRS = [
942
+ "Accounts",
943
+ "Calendars",
944
+ "CallServices",
945
+ "CloudStorage",
946
+ "Contacts",
947
+ "Cookies",
948
+ "Finance",
949
+ "FinanceBackup",
950
+ "Google",
951
+ "HomeKit",
952
+ "IdentityServices",
953
+ "Mail",
954
+ "Messages",
955
+ "Mobile Documents",
956
+ "News",
957
+ "Passes",
958
+ "PersonalizationPortrait",
959
+ "Photos",
960
+ "Safari",
961
+ "SafariSafeBrowsing",
962
+ "Sharing",
963
+ "Suggestions",
964
+ "Thunderbird",
965
+ "WebKit",
966
+ "com.apple.appleaccountd",
967
+ "com.apple.iTunesCloud"
968
+ ];
969
+ const PROTECTED_CONTAINER_PATTERNS = [
970
+ "com.bitwarden.*",
971
+ "com.agilebits.*",
972
+ "com.1password.*",
973
+ "com.moneymoney-app.*"
974
+ ];
1024
975
  function parseLines(filePath) {
1025
976
  if (!existsSync(filePath)) return [];
1026
977
  return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
@@ -1041,10 +992,27 @@ function toGlobPattern(line) {
1041
992
  function resolveGlobMatches(pattern, baseDir) {
1042
993
  return globSync(toGlobPattern(pattern), { cwd: baseDir }).map((match) => resolve(baseDir, match));
1043
994
  }
995
+ /**
996
+ * A directory is self-protected if it contains a `.bxprotect` file
997
+ * or a `.bxignore` with a bare `/` entry. Self-protected directories
998
+ * are blocked entirely — they cannot be used as workdirs and are
999
+ * denied inside workdir trees.
1000
+ */
1001
+ function isSelfProtected(dir) {
1002
+ if (existsSync(join(dir, ".bxprotect"))) return true;
1003
+ return parseLines(join(dir, ".bxignore")).some((l) => l === "/" || l === ".");
1004
+ }
1044
1005
  function applyIgnoreFile(filePath, baseDir, ignored) {
1045
- for (const line of parseLines(filePath)) ignored.push(...resolveGlobMatches(line, baseDir));
1006
+ for (const line of parseLines(filePath)) {
1007
+ if (line === "/" || line === ".") continue;
1008
+ ignored.push(...resolveGlobMatches(line, baseDir));
1009
+ }
1046
1010
  }
1047
1011
  function collectIgnoreFilesRecursive(dir, ignored) {
1012
+ if (isSelfProtected(dir)) {
1013
+ ignored.push(dir);
1014
+ return;
1015
+ }
1048
1016
  const ignoreFile = join(dir, ".bxignore");
1049
1017
  if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
1050
1018
  let entries;
@@ -1117,8 +1085,22 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
1117
1085
  }
1118
1086
  return blocked;
1119
1087
  }
1088
+ function collectProtectedContainers(home) {
1089
+ const containerDirs = ["Containers", "Group Containers"];
1090
+ const matched = [];
1091
+ for (const dir of containerDirs) {
1092
+ const base = join(home, "Library", dir);
1093
+ if (!existsSync(base)) continue;
1094
+ for (const pattern of PROTECTED_CONTAINER_PATTERNS) matched.push(...globSync(pattern, { cwd: base }).map((m) => join(base, m)));
1095
+ }
1096
+ return matched;
1097
+ }
1120
1098
  function collectIgnoredPaths(home, workDirs) {
1121
- const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
1099
+ const ignored = [
1100
+ ...PROTECTED_DOTDIRS.map((d) => join(home, d)),
1101
+ ...PROTECTED_LIBRARY_DIRS.map((d) => join(home, "Library", d)),
1102
+ ...new Set(collectProtectedContainers(home))
1103
+ ];
1122
1104
  const globalIgnore = join(home, ".bxignore");
1123
1105
  if (existsSync(globalIgnore)) {
1124
1106
  const denyLines = parseLines(globalIgnore).filter((l) => !ACCESS_PREFIX_RE.test(l));
@@ -1144,29 +1126,183 @@ function sbplPathRule(path) {
1144
1126
  return isDir ? sbplSubpath(path) : sbplLiteral(path);
1145
1127
  }
1146
1128
  function sbplDenyBlock(comment, verb, rules) {
1147
- if (rules.length === 0) return "";
1148
- return `\n; ${comment}\n(deny ${verb}\n${rules.join("\n")}\n)\n`;
1129
+ const unique = [...new Set(rules)];
1130
+ if (unique.length === 0) return "";
1131
+ return `\n; ${comment}\n(deny ${verb}\n${unique.join("\n")}\n)\n`;
1149
1132
  }
1150
- function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = []) {
1133
+ function collectSystemDenyPaths(home) {
1134
+ const paths = [];
1135
+ if (existsSync("/Volumes")) paths.push("/Volumes");
1136
+ try {
1137
+ for (const name of readdirSync("/Users")) {
1138
+ const userDir = join("/Users", name);
1139
+ if (userDir === home || name === "Shared") continue;
1140
+ try {
1141
+ if (statSync(userDir).isDirectory()) paths.push(userDir);
1142
+ } catch {}
1143
+ }
1144
+ } catch {}
1145
+ return paths;
1146
+ }
1147
+ function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1151
1148
  const blockedRules = sbplDenyBlock("Blocked directories (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplSubpath));
1152
1149
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1153
1150
  const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1151
+ const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
1154
1152
  return `; Auto-generated sandbox profile
1155
1153
  ; Working directories: ${workDirs.join(", ")}
1156
1154
 
1157
1155
  (version 1)
1158
1156
  (allow default)
1159
- ${blockedRules}${ignoredRules}${readOnlyRules}
1157
+ ${blockedRules}${ignoredRules}${readOnlyRules}${systemRules}
1160
1158
  `;
1161
1159
  }
1162
1160
  //#endregion
1161
+ //#region src/guards.ts
1162
+ /**
1163
+ * Abort if we're already inside a bx sandbox (env var set by us).
1164
+ */
1165
+ function checkOwnSandbox() {
1166
+ if (process$1.env.CODEBOX_SANDBOX === "1") {
1167
+ console.error(`\n${fmt.error("already running inside a bx sandbox")}`);
1168
+ console.error(fmt.detail("nesting sandbox-exec causes silent failures\n"));
1169
+ process$1.exit(1);
1170
+ }
1171
+ }
1172
+ /**
1173
+ * Warn if launched from inside a VSCode terminal.
1174
+ */
1175
+ function checkVSCodeTerminal() {
1176
+ if (process$1.env.VSCODE_PID) {
1177
+ console.error(`\n${fmt.warn("running from inside a VSCode terminal")}`);
1178
+ console.error(fmt.detail("this will launch a *new* instance in a sandbox"));
1179
+ console.error(fmt.detail("the current VSCode instance will NOT be sandboxed"));
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Abort if any workdir IS $HOME or is not inside $HOME.
1184
+ */
1185
+ function checkWorkDirs(workDirs, home) {
1186
+ for (const dir of workDirs) {
1187
+ if (dir === home) {
1188
+ console.error(`\n${fmt.error("working directory cannot be $HOME itself")}`);
1189
+ console.error(fmt.detail("sandboxing your entire home directory is not supported\n"));
1190
+ process$1.exit(1);
1191
+ }
1192
+ if (!dir.startsWith(home + "/")) {
1193
+ console.error(`\n${fmt.error(`working directory is outside $HOME: ${dir}`)}`);
1194
+ console.error(fmt.detail("only directories inside $HOME are supported\n"));
1195
+ process$1.exit(1);
1196
+ }
1197
+ if (isSelfProtected(dir)) {
1198
+ console.error(`\n${fmt.error(`working directory is self-protected: ${dir}`)}`);
1199
+ console.error(fmt.detail("remove .bxprotect or '/' from .bxignore to allow access\n"));
1200
+ process$1.exit(1);
1201
+ }
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Warn if the target app is already running — the new workspace will open
1206
+ * in the existing (unsandboxed) instance, bypassing our sandbox profile.
1207
+ */
1208
+ async function checkAppAlreadyRunning(mode, apps) {
1209
+ if (BUILTIN_MODES.includes(mode)) return;
1210
+ const app = apps[mode];
1211
+ if (!app?.bundle) return;
1212
+ let running = false;
1213
+ try {
1214
+ running = execFileSync("lsappinfo", ["list"], {
1215
+ encoding: "utf-8",
1216
+ timeout: 3e3
1217
+ }).includes(`bundleID="${app.bundle}"`);
1218
+ } catch {
1219
+ return;
1220
+ }
1221
+ if (!running) return;
1222
+ console.error(`\n${fmt.warn(`"${mode}" is already running`)}`);
1223
+ console.error(fmt.detail("the workspace will open in the EXISTING instance — sandbox will NOT apply"));
1224
+ if (mode === "code") console.error(fmt.detail("quit the app first, or use --profile-sandbox for an isolated instance"));
1225
+ else console.error(fmt.detail("quit the app first to ensure sandbox protection"));
1226
+ const rl = createInterface({
1227
+ input: process$1.stdin,
1228
+ output: process$1.stderr
1229
+ });
1230
+ const answer = await new Promise((res) => {
1231
+ rl.question(` continue without sandbox? [y/N] `, res);
1232
+ });
1233
+ rl.close();
1234
+ if (!answer.match(/^y(es)?$/i)) process$1.exit(0);
1235
+ }
1236
+ /**
1237
+ * Detect if we're inside an unknown sandbox by probing well-known
1238
+ * directories that exist on every Mac but would be blocked.
1239
+ */
1240
+ function checkExternalSandbox() {
1241
+ for (const dir of [
1242
+ "Documents",
1243
+ "Desktop",
1244
+ "Downloads"
1245
+ ]) {
1246
+ const target = join(process$1.env.HOME, dir);
1247
+ try {
1248
+ accessSync(target, constants.R_OK);
1249
+ } catch (e) {
1250
+ if (e.code === "EPERM") {
1251
+ console.error(`\n${fmt.error("already running inside a sandbox")}`);
1252
+ console.error(fmt.detail("nesting sandbox-exec may cause silent failures\n"));
1253
+ process$1.exit(1);
1254
+ }
1255
+ }
1256
+ }
1257
+ }
1258
+ //#endregion
1259
+ //#region src/args.ts
1260
+ /**
1261
+ * Parse CLI arguments. `validModes` is the list of recognized mode names
1262
+ * (builtin modes + app names from config).
1263
+ */
1264
+ function parseArgs(validModes) {
1265
+ const rawArgs = process$1.argv.slice(2);
1266
+ const verbose = rawArgs.includes("--verbose");
1267
+ const dry = rawArgs.includes("--dry");
1268
+ const profileSandbox = rawArgs.includes("--profile-sandbox");
1269
+ const positional = rawArgs.filter((a) => !a.startsWith("--"));
1270
+ const doubleDashIdx = rawArgs.indexOf("--");
1271
+ const appArgs = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
1272
+ const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
1273
+ let mode = "code";
1274
+ let workArgs;
1275
+ let implicitWorkdirs = false;
1276
+ if (beforeDash.length > 0 && validModes.includes(beforeDash[0])) {
1277
+ mode = beforeDash[0];
1278
+ workArgs = beforeDash.slice(1);
1279
+ } else workArgs = beforeDash;
1280
+ if (workArgs.length === 0) {
1281
+ workArgs = ["."];
1282
+ implicitWorkdirs = true;
1283
+ }
1284
+ if (mode === "exec" && appArgs.length === 0) {
1285
+ console.error(`\n${fmt.error("exec mode requires a command after \"--\"")}`);
1286
+ console.error(fmt.detail("usage: bx exec [workdir...] -- command [args...]\n"));
1287
+ process$1.exit(1);
1288
+ }
1289
+ return {
1290
+ mode,
1291
+ workArgs,
1292
+ verbose,
1293
+ dry,
1294
+ profileSandbox,
1295
+ appArgs,
1296
+ implicit: implicitWorkdirs
1297
+ };
1298
+ }
1299
+ //#endregion
1163
1300
  //#region src/modes.ts
1164
1301
  function isBuiltinMode(mode) {
1165
1302
  return BUILTIN_MODES.includes(mode);
1166
1303
  }
1167
- function shouldPassWorkdirs(app, mode) {
1168
- if (typeof app.passWorkdirs === "boolean") return app.passWorkdirs;
1169
- return mode !== "xcode";
1304
+ function shouldPassWorkdirs(app) {
1305
+ return app.passWorkdirs !== false;
1170
1306
  }
1171
1307
  function appBundleFromPath(path) {
1172
1308
  if (path.endsWith(".app")) return path;
@@ -1237,7 +1373,7 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1237
1373
  }
1238
1374
  if (app.args) args.push(...app.args);
1239
1375
  if (appArgs.length > 0) args.push(...appArgs);
1240
- if (shouldPassWorkdirs(app, mode)) args.push(...workDirs);
1376
+ if (shouldPassWorkdirs(app)) args.push(...workDirs);
1241
1377
  return {
1242
1378
  bin,
1243
1379
  args
@@ -1333,7 +1469,8 @@ Options:
1333
1469
 
1334
1470
  Configuration:
1335
1471
  ~/.bxconfig.toml app definitions (TOML):
1336
- [apps.name] add a new app
1472
+ [name] add a new app
1473
+ mode = "..." inherit from another app
1337
1474
  bundle = "..." macOS bundle ID (auto-discovery)
1338
1475
  binary = "..." relative path in .app bundle
1339
1476
  path = "..." explicit executable path
@@ -1345,6 +1482,8 @@ Configuration:
1345
1482
  rw:path allow read-write access
1346
1483
  ro:path allow read-only access
1347
1484
  <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
1485
+ / or . self-protect: block entire directory
1486
+ <dir>/.bxprotect marker file: block the containing directory
1348
1487
 
1349
1488
  https://github.com/holtwick/bx-mac`;
1350
1489
  //#endregion
@@ -1362,16 +1501,25 @@ function kindIcon(kind) {
1362
1501
  default: return `${RED}✖${RESET}`;
1363
1502
  }
1364
1503
  }
1365
- function insertPath(root, home, absPath, kind, isDir) {
1366
- const rel = absPath.startsWith(home + "/") ? absPath.slice(home.length + 1) : absPath;
1504
+ function insertPath(root, homeParts, absPath, kind, isDir) {
1505
+ const parts = absPath.split("/").filter(Boolean);
1367
1506
  let node = root;
1368
- for (const part of rel.split("/")) {
1507
+ for (const part of parts) {
1369
1508
  if (!node.children.has(part)) node.children.set(part, { children: /* @__PURE__ */ new Map() });
1370
1509
  node = node.children.get(part);
1371
1510
  }
1372
1511
  node.kind = kind;
1373
1512
  node.isDir = isDir;
1374
1513
  }
1514
+ /** Collapse the tree down to the interesting nodes, keeping only branches
1515
+ * that contain a leaf with a kind (blocked/ignored/workdir/read-only).
1516
+ * Intermediate directories on the home path are kept as navigation context. */
1517
+ function pruneTree(node, currentParts, homeParts, depth) {
1518
+ if (node.kind) return true;
1519
+ depth < homeParts.length && (currentParts[depth], homeParts[depth]);
1520
+ for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
1521
+ return node.children.size > 0;
1522
+ }
1375
1523
  function isDirectory(path) {
1376
1524
  try {
1377
1525
  return statSync(path).isDirectory();
@@ -1393,13 +1541,16 @@ function printNode(node, prefix) {
1393
1541
  if (child.children.size > 0) printNode(child, prefix + continuation);
1394
1542
  }
1395
1543
  }
1396
- function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs }) {
1544
+ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
1397
1545
  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}`);
1546
+ const homeParts = home.split("/").filter(Boolean);
1547
+ for (const dir of blockedDirs) insertPath(root, homeParts, dir, "blocked", true);
1548
+ for (const path of ignoredPaths) insertPath(root, homeParts, path, "ignored", isDirectory(path));
1549
+ for (const dir of readOnlyDirs) insertPath(root, homeParts, dir, "read-only", true);
1550
+ for (const dir of workDirs) insertPath(root, homeParts, dir, "workdir", true);
1551
+ for (const dir of systemDenyPaths) insertPath(root, homeParts, dir, "blocked", true);
1552
+ pruneTree(root, [], homeParts, 0);
1553
+ console.log(`\n${CYAN}/${RESET}`);
1403
1554
  printNode(root, "");
1404
1555
  console.log(`\n${RED}✖${RESET} = denied ${YELLOW}◉${RESET} = read-only ${GREEN}✔${RESET} = read-write\n`);
1405
1556
  }
@@ -1430,7 +1581,7 @@ async function main() {
1430
1581
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
1431
1582
  console.error(fmt.detail(`Usage: bx ${mode} <workdir>`));
1432
1583
  console.error(fmt.detail(`Config: set default workdirs in ~/.bxconfig.toml:\n`));
1433
- console.error(fmt.detail(`[apps.${mode}]`));
1584
+ console.error(fmt.detail(`[${mode}]`));
1434
1585
  console.error(fmt.detail(`workdirs = ["~/work/my-project"]\n`));
1435
1586
  process$1.exit(1);
1436
1587
  }
@@ -1448,7 +1599,7 @@ async function main() {
1448
1599
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1449
1600
  const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
1450
1601
  printPolicySummary(mode, workDirs, blockedDirs, ignoredPaths, readOnly);
1451
- const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly]);
1602
+ const profile = generateProfile(workDirs, blockedDirs, ignoredPaths, [...readOnly], HOME);
1452
1603
  if (verbose) {
1453
1604
  console.error("\n--- Generated sandbox profile ---");
1454
1605
  console.error(profile);
@@ -1460,7 +1611,8 @@ async function main() {
1460
1611
  blockedDirs,
1461
1612
  ignoredPaths,
1462
1613
  readOnlyDirs: readOnly,
1463
- workDirs
1614
+ workDirs,
1615
+ systemDenyPaths: collectSystemDenyPaths(HOME)
1464
1616
  });
1465
1617
  process$1.exit(0);
1466
1618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {