bx-mac 0.2.1 → 0.3.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/dist/bx.js +223 -109
- package/package.json +4 -3
package/dist/bx.js
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const __VERSION__ = "0.3.0";
|
|
2
3
|
import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
//#region src/guards.ts
|
|
9
|
+
/**
|
|
10
|
+
* Abort if we're already inside a bx sandbox (env var set by us).
|
|
11
|
+
*/
|
|
12
|
+
function checkOwnSandbox() {
|
|
13
|
+
if (process.env.CODEBOX_SANDBOX === "1") {
|
|
14
|
+
console.error("sandbox: ERROR — already running inside a bx sandbox.");
|
|
15
|
+
console.error("sandbox: Nesting sandbox-exec causes silent failures. Aborting.");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Warn if launched from inside a VSCode terminal.
|
|
21
|
+
*/
|
|
22
|
+
function checkVSCodeTerminal() {
|
|
23
|
+
if (process.env.VSCODE_PID) {
|
|
24
|
+
console.error("sandbox: WARNING — running from inside a VSCode terminal.");
|
|
25
|
+
console.error("sandbox: This will launch a *new* instance in a sandbox.");
|
|
26
|
+
console.error("sandbox: The current VSCode instance will NOT be sandboxed.");
|
|
27
|
+
}
|
|
17
28
|
}
|
|
18
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Detect if we're inside an unknown sandbox by probing well-known
|
|
31
|
+
* directories that exist on every Mac but would be blocked.
|
|
32
|
+
*/
|
|
33
|
+
function checkExternalSandbox() {
|
|
19
34
|
for (const dir of [
|
|
20
35
|
"Documents",
|
|
21
36
|
"Desktop",
|
|
@@ -25,141 +40,187 @@ function isAlreadySandboxed() {
|
|
|
25
40
|
try {
|
|
26
41
|
accessSync(target, constants.R_OK);
|
|
27
42
|
} catch (e) {
|
|
28
|
-
if (e.code === "EPERM")
|
|
43
|
+
if (e.code === "EPERM") {
|
|
44
|
+
console.error("sandbox: ERROR — already running inside a sandbox!");
|
|
45
|
+
console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
29
48
|
}
|
|
30
49
|
}
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
if (isAlreadySandboxed()) {
|
|
34
|
-
console.error("sandbox: ERROR — already running inside a sandbox!");
|
|
35
|
-
console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
|
|
36
|
-
process.exit(1);
|
|
37
50
|
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/args.ts
|
|
38
53
|
const MODES = [
|
|
39
54
|
"code",
|
|
40
55
|
"term",
|
|
41
56
|
"claude",
|
|
42
57
|
"exec"
|
|
43
58
|
];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
function parseArgs() {
|
|
60
|
+
const rawArgs = process.argv.slice(2);
|
|
61
|
+
const verbose = rawArgs.includes("--verbose");
|
|
62
|
+
const profileSandbox = rawArgs.includes("--profile-sandbox");
|
|
63
|
+
const positional = rawArgs.filter((a) => !a.startsWith("--"));
|
|
64
|
+
const doubleDashIdx = rawArgs.indexOf("--");
|
|
65
|
+
const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
|
|
66
|
+
const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
|
|
67
|
+
let mode = "code";
|
|
68
|
+
let workArgs;
|
|
69
|
+
if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
|
|
70
|
+
mode = beforeDash[0];
|
|
71
|
+
workArgs = beforeDash.slice(1);
|
|
72
|
+
} else workArgs = beforeDash;
|
|
73
|
+
if (workArgs.length === 0) workArgs = ["."];
|
|
74
|
+
if (mode === "exec" && execCmd.length === 0) {
|
|
75
|
+
console.error("sandbox: exec mode requires a command after \"--\"");
|
|
76
|
+
console.error("usage: bx exec [workdir...] -- command [args...]");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
mode,
|
|
81
|
+
workArgs,
|
|
82
|
+
verbose,
|
|
83
|
+
profileSandbox,
|
|
84
|
+
execCmd
|
|
85
|
+
};
|
|
61
86
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/profile.ts
|
|
89
|
+
const PROTECTED_DOTDIRS = [
|
|
90
|
+
".Trash",
|
|
91
|
+
".ssh",
|
|
92
|
+
".gnupg",
|
|
93
|
+
".docker",
|
|
94
|
+
".zsh_sessions",
|
|
95
|
+
".cargo",
|
|
96
|
+
".gradle",
|
|
97
|
+
".gem"
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Parse a config file with one entry per line (supports # comments).
|
|
101
|
+
*/
|
|
102
|
+
function parseLines(filePath) {
|
|
103
|
+
if (!existsSync(filePath)) return [];
|
|
104
|
+
return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
72
105
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Apply a single .bxignore file: resolve glob patterns relative to baseDir.
|
|
108
|
+
*/
|
|
109
|
+
function applyIgnoreFile(filePath, baseDir, ignored) {
|
|
110
|
+
for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Recursively find and apply .bxignore files in a directory tree.
|
|
114
|
+
*/
|
|
115
|
+
function collectIgnoreFilesRecursive(dir, ignored) {
|
|
116
|
+
const ignoreFile = join(dir, ".bxignore");
|
|
117
|
+
if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(dir);
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
82
123
|
}
|
|
124
|
+
for (const name of entries) {
|
|
125
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
126
|
+
const fullPath = join(dir, name);
|
|
127
|
+
try {
|
|
128
|
+
if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Parse ~/.bxallow and return a set of all allowed directories.
|
|
134
|
+
*/
|
|
135
|
+
function parseAllowedDirs(home, workDirs) {
|
|
136
|
+
const allowed = new Set(workDirs);
|
|
137
|
+
for (const line of parseLines(join(home, ".bxallow"))) {
|
|
138
|
+
const absolute = resolve(home, line);
|
|
139
|
+
if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
|
|
140
|
+
}
|
|
141
|
+
return allowed;
|
|
83
142
|
}
|
|
84
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Recursively collect directories to block under parentDir.
|
|
145
|
+
* Never blocks a parent of an allowed path — instead descends and blocks siblings.
|
|
146
|
+
*/
|
|
147
|
+
function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
|
|
85
148
|
const blocked = [];
|
|
86
149
|
for (const name of readdirSync(parentDir)) {
|
|
87
150
|
if (name.startsWith(".")) continue;
|
|
88
151
|
const fullPath = join(parentDir, name);
|
|
89
152
|
if (!statSync(fullPath).isDirectory()) continue;
|
|
90
|
-
if (parentDir ===
|
|
91
|
-
if (
|
|
153
|
+
if (parentDir === home && name === "Library") continue;
|
|
154
|
+
if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
|
|
92
155
|
if (allowedDirs.has(fullPath)) continue;
|
|
93
156
|
if ([...allowedDirs].some((d) => d.startsWith(fullPath + "/"))) {
|
|
94
|
-
blocked.push(...collectBlockedDirs(fullPath));
|
|
157
|
+
blocked.push(...collectBlockedDirs(fullPath, home, scriptDir, allowedDirs));
|
|
95
158
|
continue;
|
|
96
159
|
}
|
|
97
160
|
blocked.push(fullPath);
|
|
98
161
|
}
|
|
99
162
|
return blocked;
|
|
100
163
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
".
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
".gem"
|
|
111
|
-
];
|
|
112
|
-
const ignoredPaths = PROTECTED_DOTDIRS.map((d) => join(HOME, d));
|
|
113
|
-
function parseSandboxIgnore(filePath, baseDir) {
|
|
114
|
-
if (!existsSync(filePath)) return;
|
|
115
|
-
for (const raw of readFileSync(filePath, "utf-8").split("\n")) {
|
|
116
|
-
const line = raw.trim();
|
|
117
|
-
if (!line || line.startsWith("#")) continue;
|
|
118
|
-
const matches = globSync(line, { cwd: baseDir });
|
|
119
|
-
for (const match of matches) ignoredPaths.push(resolve(baseDir, match));
|
|
120
|
-
}
|
|
164
|
+
/**
|
|
165
|
+
* Collect paths to deny from .bxignore files and built-in protected dotdirs.
|
|
166
|
+
* Searches ~/.bxignore and recursively through all workdirs.
|
|
167
|
+
*/
|
|
168
|
+
function collectIgnoredPaths(home, workDirs) {
|
|
169
|
+
const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
|
|
170
|
+
applyIgnoreFile(join(home, ".bxignore"), home, ignored);
|
|
171
|
+
for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
|
|
172
|
+
return ignored;
|
|
121
173
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
;
|
|
174
|
+
/**
|
|
175
|
+
* Generate the SBPL sandbox profile string.
|
|
176
|
+
*/
|
|
177
|
+
function generateProfile(workDirs, blockedDirs, ignoredPaths) {
|
|
178
|
+
const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
|
|
179
|
+
const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
|
|
180
|
+
return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
|
|
181
|
+
}).join("\n")}\n)\n` : "";
|
|
182
|
+
return `; Auto-generated sandbox profile
|
|
183
|
+
; Working directories: ${workDirs.join(", ")}
|
|
128
184
|
|
|
129
185
|
(version 1)
|
|
130
186
|
(allow default)
|
|
131
187
|
|
|
132
188
|
; Blocked directories (auto-generated from $HOME contents)
|
|
133
189
|
(deny file*
|
|
134
|
-
${
|
|
190
|
+
${denyRules}
|
|
135
191
|
)
|
|
136
|
-
|
|
137
|
-
${ignoredPaths.length > 0 ? `
|
|
138
|
-
; Hidden paths from .bxignore
|
|
139
|
-
(deny file*
|
|
140
|
-
${ignoredPaths.map((p) => {
|
|
141
|
-
return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
|
|
142
|
-
}).join("\n")}
|
|
143
|
-
)
|
|
144
|
-
` : ""}
|
|
192
|
+
${ignoredRules}
|
|
145
193
|
`;
|
|
146
|
-
const profilePath = join("/tmp", `bx-${process.pid}.sb`);
|
|
147
|
-
writeFileSync(profilePath, profile);
|
|
148
|
-
console.error(`sandbox: ${mode} mode, working directory: ${WORK_DIR}`);
|
|
149
|
-
if (verbose) {
|
|
150
|
-
console.error("\n--- Generated sandbox profile ---");
|
|
151
|
-
console.error(profile);
|
|
152
|
-
console.error("--- End of profile ---\n");
|
|
153
194
|
}
|
|
154
|
-
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/modes.ts
|
|
197
|
+
const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
|
|
198
|
+
/**
|
|
199
|
+
* Prepare VSCode isolated profile if --profile-sandbox is set.
|
|
200
|
+
*/
|
|
201
|
+
function setupVSCodeProfile(home) {
|
|
202
|
+
const dataDir = join(home, ".vscode-sandbox");
|
|
203
|
+
const globalExt = join(home, ".vscode", "extensions");
|
|
204
|
+
const localExt = join(dataDir, "extensions");
|
|
205
|
+
mkdirSync(dataDir, { recursive: true });
|
|
206
|
+
if (!existsSync(localExt) && existsSync(globalExt)) {
|
|
207
|
+
console.error("sandbox: copying extensions from global install...");
|
|
208
|
+
cpSync(globalExt, localExt, { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Build the command + args to run inside the sandbox for the given mode.
|
|
213
|
+
*/
|
|
214
|
+
function buildCommand(mode, workDirs, home, profileSandbox, execCmd) {
|
|
155
215
|
switch (mode) {
|
|
156
216
|
case "code": {
|
|
217
|
+
const dataDir = join(home, ".vscode-sandbox");
|
|
157
218
|
const args = ["--no-sandbox"];
|
|
158
219
|
if (profileSandbox) {
|
|
159
|
-
args.push("--user-data-dir", join(
|
|
160
|
-
args.push("--extensions-dir",
|
|
220
|
+
args.push("--user-data-dir", join(dataDir, "data"));
|
|
221
|
+
args.push("--extensions-dir", join(dataDir, "extensions"));
|
|
161
222
|
}
|
|
162
|
-
args.push(
|
|
223
|
+
args.push(...workDirs);
|
|
163
224
|
return {
|
|
164
225
|
bin: VSCODE_APP,
|
|
165
226
|
args
|
|
@@ -171,7 +232,7 @@ function buildCommand() {
|
|
|
171
232
|
};
|
|
172
233
|
case "claude": return {
|
|
173
234
|
bin: "claude",
|
|
174
|
-
args: [
|
|
235
|
+
args: [workDirs[0]]
|
|
175
236
|
};
|
|
176
237
|
case "exec": return {
|
|
177
238
|
bin: execCmd[0],
|
|
@@ -179,18 +240,71 @@ function buildCommand() {
|
|
|
179
240
|
};
|
|
180
241
|
}
|
|
181
242
|
}
|
|
182
|
-
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/index.ts
|
|
245
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
246
|
+
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
|
|
247
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
248
|
+
console.log(`bx ${VERSION}`);
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
252
|
+
console.log(`bx ${VERSION} — launch apps in a macOS sandbox
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
bx [workdir...] VSCode (default)
|
|
256
|
+
bx code [workdir...] VSCode
|
|
257
|
+
bx term [workdir...] sandboxed login shell
|
|
258
|
+
bx claude [workdir...] Claude Code CLI
|
|
259
|
+
bx exec [workdir...] -- command [args...] arbitrary command
|
|
260
|
+
|
|
261
|
+
Options:
|
|
262
|
+
--verbose print the generated sandbox profile
|
|
263
|
+
--profile-sandbox use an isolated VSCode profile (code mode only)
|
|
264
|
+
-v, --version show version
|
|
265
|
+
-h, --help show this help
|
|
266
|
+
|
|
267
|
+
Configuration:
|
|
268
|
+
~/.bxallow extra allowed directories (one per line)
|
|
269
|
+
~/.bxignore extra blocked paths in $HOME (one per line)
|
|
270
|
+
<workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
|
|
271
|
+
|
|
272
|
+
https://github.com/holtwick/bx-mac`);
|
|
273
|
+
process.exit(0);
|
|
274
|
+
}
|
|
275
|
+
checkOwnSandbox();
|
|
276
|
+
checkVSCodeTerminal();
|
|
277
|
+
checkExternalSandbox();
|
|
278
|
+
const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
|
|
279
|
+
const HOME = process.env.HOME;
|
|
280
|
+
const WORK_DIRS = workArgs.map((a) => resolve(a));
|
|
281
|
+
if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
|
|
282
|
+
const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
|
|
283
|
+
const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
|
|
284
|
+
const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
|
|
285
|
+
if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
|
|
286
|
+
const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
|
|
287
|
+
const profilePath = join("/tmp", `bx-${process.pid}.sb`);
|
|
288
|
+
writeFileSync(profilePath, profile);
|
|
289
|
+
const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
|
|
290
|
+
console.error(`sandbox: ${mode} mode, working directory: ${dirLabel}`);
|
|
291
|
+
if (verbose) {
|
|
292
|
+
console.error("\n--- Generated sandbox profile ---");
|
|
293
|
+
console.error(profile);
|
|
294
|
+
console.error("--- End of profile ---\n");
|
|
295
|
+
}
|
|
296
|
+
const cmd = buildCommand(mode, WORK_DIRS, HOME, profileSandbox, execCmd);
|
|
183
297
|
spawn("sandbox-exec", [
|
|
184
298
|
"-f",
|
|
185
299
|
profilePath,
|
|
186
300
|
"-D",
|
|
187
301
|
`HOME=${HOME}`,
|
|
188
302
|
"-D",
|
|
189
|
-
`WORK=${
|
|
303
|
+
`WORK=${WORK_DIRS[0]}`,
|
|
190
304
|
cmd.bin,
|
|
191
305
|
...cmd.args
|
|
192
306
|
], {
|
|
193
|
-
cwd:
|
|
307
|
+
cwd: WORK_DIRS[0],
|
|
194
308
|
stdio: "inherit",
|
|
195
309
|
env: {
|
|
196
310
|
...process.env,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bx-mac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Launch apps in a macOS sandbox — only the project directory is accessible",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "rolldown -c",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"post:release": "./scripts/release.sh"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"sandbox",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"author": "Dirk Holtwick",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"devDependencies": {
|
|
27
|
+
"@types/node": "^25.5.0",
|
|
27
28
|
"rolldown": "^1.0.0-rc.12"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|