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.
- package/README.md +62 -8
- package/dist/bx.js +324 -170
- 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
|
|
|
@@ -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
|
-
> 🔒 `.
|
|
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
|
-
[
|
|
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,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))
|
|
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 =
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
|
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
|
|
1168
|
-
|
|
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
|
|
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
|
-
[
|
|
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,
|
|
1366
|
-
const
|
|
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
|
|
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
|
-
|
|
1399
|
-
for (const
|
|
1400
|
-
for (const
|
|
1401
|
-
for (const dir of
|
|
1402
|
-
|
|
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 =
|
|
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(`[
|
|
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
|
}
|