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.
- package/README.md +61 -7
- package/dist/bx.js +320 -168
- 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 `[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
147
|
+
[zed]
|
|
148
148
|
path = "/Applications/Zed.app/Contents/MacOS/zed"
|
|
149
149
|
|
|
150
150
|
# Override built-in VSCode path
|
|
151
|
-
[
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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))
|
|
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 =
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
|
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
|
|
1168
|
-
|
|
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
|
|
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
|
-
[
|
|
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,
|
|
1366
|
-
const
|
|
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
|
|
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
|
-
|
|
1399
|
-
for (const
|
|
1400
|
-
for (const
|
|
1401
|
-
for (const dir of
|
|
1402
|
-
|
|
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(`[
|
|
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
|
}
|