bx-mac 0.2.1 → 0.4.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 +31 -11
- package/dist/bx.js +241 -109
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -14,6 +14,12 @@ bx ~/work/my-project
|
|
|
14
14
|
|
|
15
15
|
That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing else.
|
|
16
16
|
|
|
17
|
+
Need multiple directories? No problem:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bx ~/work/my-project ~/work/shared-lib
|
|
21
|
+
```
|
|
22
|
+
|
|
17
23
|
## ✅ What it does
|
|
18
24
|
|
|
19
25
|
- 🔒 Blocks `~/Documents`, `~/Desktop`, `~/Downloads`, and all other personal folders
|
|
@@ -21,8 +27,9 @@ That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing
|
|
|
21
27
|
- 🛡️ Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo`
|
|
22
28
|
- ⚙️ Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
|
|
23
29
|
- 🔍 Generates sandbox rules dynamically based on your actual `$HOME` contents
|
|
24
|
-
- 📝 Supports `.bxignore` to hide secrets like `.env` files within a project
|
|
30
|
+
- 📝 Supports `.bxignore` files (searched recursively) to hide secrets like `.env` files within a project
|
|
25
31
|
- 📂 Supports `~/.bxallow` to grant access to shared utility directories
|
|
32
|
+
- 🗂️ Supports multiple working directories in a single sandbox
|
|
26
33
|
|
|
27
34
|
## 🚫 What it doesn't do
|
|
28
35
|
|
|
@@ -54,13 +61,13 @@ pnpm link -g
|
|
|
54
61
|
|
|
55
62
|
| Command | What it launches |
|
|
56
63
|
|---|---|
|
|
57
|
-
| `bx [workdir]` | 🖥️ VSCode (default) |
|
|
58
|
-
| `bx code [workdir]` | 🖥️ VSCode (explicit) |
|
|
59
|
-
| `bx term [workdir]` | 💻 Sandboxed login shell (`$SHELL -l`) |
|
|
60
|
-
| `bx claude [workdir]` | 🤖 Claude Code CLI |
|
|
61
|
-
| `bx exec [workdir] -- cmd` | ⚡ Any command you want |
|
|
64
|
+
| `bx [workdir...]` | 🖥️ VSCode (default) |
|
|
65
|
+
| `bx code [workdir...]` | 🖥️ VSCode (explicit) |
|
|
66
|
+
| `bx term [workdir...]` | 💻 Sandboxed login shell (`$SHELL -l`) |
|
|
67
|
+
| `bx claude [workdir...]` | 🤖 Claude Code CLI |
|
|
68
|
+
| `bx exec [workdir...] -- cmd` | ⚡ Any command you want |
|
|
62
69
|
|
|
63
|
-
If no directory is given, the current directory is used.
|
|
70
|
+
If no directory is given, the current directory is used. All modes accept multiple directories.
|
|
64
71
|
|
|
65
72
|
### Examples
|
|
66
73
|
|
|
@@ -68,6 +75,9 @@ If no directory is given, the current directory is used.
|
|
|
68
75
|
# 🖥️ VSCode with sandbox protection
|
|
69
76
|
bx ~/work/my-project
|
|
70
77
|
|
|
78
|
+
# 📂 Multiple working directories
|
|
79
|
+
bx ~/work/my-project ~/work/shared-lib
|
|
80
|
+
|
|
71
81
|
# 💻 Work on a project in a sandboxed terminal
|
|
72
82
|
bx term ~/work/my-project
|
|
73
83
|
|
|
@@ -90,7 +100,7 @@ bx --verbose ~/work/my-project
|
|
|
90
100
|
|
|
91
101
|
## 📝 Configuration
|
|
92
102
|
|
|
93
|
-
bx uses three optional config files — one entry per line, `#` for comments.
|
|
103
|
+
bx uses three optional config files — one entry per line, `#` for comments. Project `.bxignore` files are discovered recursively.
|
|
94
104
|
|
|
95
105
|
### `~/.bxallow`
|
|
96
106
|
|
|
@@ -119,7 +129,7 @@ These are blocked **in addition** to the built-in protected list:
|
|
|
119
129
|
|
|
120
130
|
### `<project>/.bxignore`
|
|
121
131
|
|
|
122
|
-
Block paths within the working directory. Supports glob patterns.
|
|
132
|
+
Block paths within the working directory. Supports glob patterns. bx searches for `.bxignore` files **recursively** through the entire project tree (skipping `.`-prefixed dirs and `node_modules`), so you can place them in subdirectories to hide secrets close to where they live.
|
|
123
133
|
|
|
124
134
|
```gitignore
|
|
125
135
|
.env
|
|
@@ -129,15 +139,25 @@ secrets/
|
|
|
129
139
|
**/*.key
|
|
130
140
|
```
|
|
131
141
|
|
|
142
|
+
For example, a monorepo might have:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
my-project/.bxignore # top-level rules
|
|
146
|
+
my-project/services/api/.bxignore # API-specific secrets
|
|
147
|
+
my-project/deploy/.bxignore # deployment credentials
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each `.bxignore` resolves its patterns relative to its own directory.
|
|
151
|
+
|
|
132
152
|
## 🔧 How it works
|
|
133
153
|
|
|
134
154
|
bx generates a macOS sandbox profile at launch time:
|
|
135
155
|
|
|
136
156
|
1. **Scan** `$HOME` for non-hidden directories
|
|
137
157
|
2. **Block** each one individually with `(deny file* (subpath ...))`
|
|
138
|
-
3. **Skip**
|
|
158
|
+
3. **Skip** all working directories, `~/Library`, dotfiles, and `~/.bxallow` paths
|
|
139
159
|
4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules)
|
|
140
|
-
5. **Append** deny rules for protected dotdirs and `.bxignore`
|
|
160
|
+
5. **Append** deny rules for protected dotdirs, `~/.bxignore`, and `.bxignore` files found recursively in each working directory
|
|
141
161
|
6. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
|
|
142
162
|
|
|
143
163
|
### Why not a simple deny-all + allow?
|
package/dist/bx.js
CHANGED
|
@@ -1,21 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const __VERSION__ = "0.4.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
|
+
}
|
|
18
|
+
}
|
|
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
|
+
}
|
|
12
28
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Abort if any workdir IS $HOME or is not inside $HOME.
|
|
31
|
+
*/
|
|
32
|
+
function checkWorkDirs(workDirs, home) {
|
|
33
|
+
for (const dir of workDirs) {
|
|
34
|
+
if (dir === home) {
|
|
35
|
+
console.error("sandbox: ERROR — working directory cannot be $HOME itself.");
|
|
36
|
+
console.error("sandbox: Sandboxing your entire home directory is not supported. Aborting.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (!dir.startsWith(home + "/")) {
|
|
40
|
+
console.error(`sandbox: ERROR — working directory is outside $HOME: ${dir}`);
|
|
41
|
+
console.error("sandbox: Only directories inside $HOME are supported. Aborting.");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
17
45
|
}
|
|
18
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Detect if we're inside an unknown sandbox by probing well-known
|
|
48
|
+
* directories that exist on every Mac but would be blocked.
|
|
49
|
+
*/
|
|
50
|
+
function checkExternalSandbox() {
|
|
19
51
|
for (const dir of [
|
|
20
52
|
"Documents",
|
|
21
53
|
"Desktop",
|
|
@@ -25,141 +57,187 @@ function isAlreadySandboxed() {
|
|
|
25
57
|
try {
|
|
26
58
|
accessSync(target, constants.R_OK);
|
|
27
59
|
} catch (e) {
|
|
28
|
-
if (e.code === "EPERM")
|
|
60
|
+
if (e.code === "EPERM") {
|
|
61
|
+
console.error("sandbox: ERROR — already running inside a sandbox!");
|
|
62
|
+
console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
29
65
|
}
|
|
30
66
|
}
|
|
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
67
|
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/args.ts
|
|
38
70
|
const MODES = [
|
|
39
71
|
"code",
|
|
40
72
|
"term",
|
|
41
73
|
"claude",
|
|
42
74
|
"exec"
|
|
43
75
|
];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
function parseArgs() {
|
|
77
|
+
const rawArgs = process.argv.slice(2);
|
|
78
|
+
const verbose = rawArgs.includes("--verbose");
|
|
79
|
+
const profileSandbox = rawArgs.includes("--profile-sandbox");
|
|
80
|
+
const positional = rawArgs.filter((a) => !a.startsWith("--"));
|
|
81
|
+
const doubleDashIdx = rawArgs.indexOf("--");
|
|
82
|
+
const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
|
|
83
|
+
const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
|
|
84
|
+
let mode = "code";
|
|
85
|
+
let workArgs;
|
|
86
|
+
if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
|
|
87
|
+
mode = beforeDash[0];
|
|
88
|
+
workArgs = beforeDash.slice(1);
|
|
89
|
+
} else workArgs = beforeDash;
|
|
90
|
+
if (workArgs.length === 0) workArgs = ["."];
|
|
91
|
+
if (mode === "exec" && execCmd.length === 0) {
|
|
92
|
+
console.error("sandbox: exec mode requires a command after \"--\"");
|
|
93
|
+
console.error("usage: bx exec [workdir...] -- command [args...]");
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
mode,
|
|
98
|
+
workArgs,
|
|
99
|
+
verbose,
|
|
100
|
+
profileSandbox,
|
|
101
|
+
execCmd
|
|
102
|
+
};
|
|
61
103
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/profile.ts
|
|
106
|
+
const PROTECTED_DOTDIRS = [
|
|
107
|
+
".Trash",
|
|
108
|
+
".ssh",
|
|
109
|
+
".gnupg",
|
|
110
|
+
".docker",
|
|
111
|
+
".zsh_sessions",
|
|
112
|
+
".cargo",
|
|
113
|
+
".gradle",
|
|
114
|
+
".gem"
|
|
115
|
+
];
|
|
116
|
+
/**
|
|
117
|
+
* Parse a config file with one entry per line (supports # comments).
|
|
118
|
+
*/
|
|
119
|
+
function parseLines(filePath) {
|
|
120
|
+
if (!existsSync(filePath)) return [];
|
|
121
|
+
return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
72
122
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Apply a single .bxignore file: resolve glob patterns relative to baseDir.
|
|
125
|
+
*/
|
|
126
|
+
function applyIgnoreFile(filePath, baseDir, ignored) {
|
|
127
|
+
for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Recursively find and apply .bxignore files in a directory tree.
|
|
131
|
+
*/
|
|
132
|
+
function collectIgnoreFilesRecursive(dir, ignored) {
|
|
133
|
+
const ignoreFile = join(dir, ".bxignore");
|
|
134
|
+
if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
|
|
135
|
+
let entries;
|
|
136
|
+
try {
|
|
137
|
+
entries = readdirSync(dir);
|
|
138
|
+
} catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const name of entries) {
|
|
142
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
143
|
+
const fullPath = join(dir, name);
|
|
144
|
+
try {
|
|
145
|
+
if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
|
|
146
|
+
} catch {}
|
|
82
147
|
}
|
|
83
148
|
}
|
|
84
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Parse ~/.bxallow and return a set of all allowed directories.
|
|
151
|
+
*/
|
|
152
|
+
function parseAllowedDirs(home, workDirs) {
|
|
153
|
+
const allowed = new Set(workDirs);
|
|
154
|
+
for (const line of parseLines(join(home, ".bxallow"))) {
|
|
155
|
+
const absolute = resolve(home, line);
|
|
156
|
+
if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
|
|
157
|
+
}
|
|
158
|
+
return allowed;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Recursively collect directories to block under parentDir.
|
|
162
|
+
* Never blocks a parent of an allowed path — instead descends and blocks siblings.
|
|
163
|
+
*/
|
|
164
|
+
function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
|
|
85
165
|
const blocked = [];
|
|
86
166
|
for (const name of readdirSync(parentDir)) {
|
|
87
167
|
if (name.startsWith(".")) continue;
|
|
88
168
|
const fullPath = join(parentDir, name);
|
|
89
169
|
if (!statSync(fullPath).isDirectory()) continue;
|
|
90
|
-
if (parentDir ===
|
|
91
|
-
if (
|
|
170
|
+
if (parentDir === home && name === "Library") continue;
|
|
171
|
+
if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
|
|
92
172
|
if (allowedDirs.has(fullPath)) continue;
|
|
93
173
|
if ([...allowedDirs].some((d) => d.startsWith(fullPath + "/"))) {
|
|
94
|
-
blocked.push(...collectBlockedDirs(fullPath));
|
|
174
|
+
blocked.push(...collectBlockedDirs(fullPath, home, scriptDir, allowedDirs));
|
|
95
175
|
continue;
|
|
96
176
|
}
|
|
97
177
|
blocked.push(fullPath);
|
|
98
178
|
}
|
|
99
179
|
return blocked;
|
|
100
180
|
}
|
|
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
|
-
}
|
|
181
|
+
/**
|
|
182
|
+
* Collect paths to deny from .bxignore files and built-in protected dotdirs.
|
|
183
|
+
* Searches ~/.bxignore and recursively through all workdirs.
|
|
184
|
+
*/
|
|
185
|
+
function collectIgnoredPaths(home, workDirs) {
|
|
186
|
+
const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
|
|
187
|
+
applyIgnoreFile(join(home, ".bxignore"), home, ignored);
|
|
188
|
+
for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
|
|
189
|
+
return ignored;
|
|
121
190
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
;
|
|
191
|
+
/**
|
|
192
|
+
* Generate the SBPL sandbox profile string.
|
|
193
|
+
*/
|
|
194
|
+
function generateProfile(workDirs, blockedDirs, ignoredPaths) {
|
|
195
|
+
const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
|
|
196
|
+
const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
|
|
197
|
+
return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
|
|
198
|
+
}).join("\n")}\n)\n` : "";
|
|
199
|
+
return `; Auto-generated sandbox profile
|
|
200
|
+
; Working directories: ${workDirs.join(", ")}
|
|
128
201
|
|
|
129
202
|
(version 1)
|
|
130
203
|
(allow default)
|
|
131
204
|
|
|
132
205
|
; Blocked directories (auto-generated from $HOME contents)
|
|
133
206
|
(deny file*
|
|
134
|
-
${
|
|
207
|
+
${denyRules}
|
|
135
208
|
)
|
|
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
|
-
` : ""}
|
|
209
|
+
${ignoredRules}
|
|
145
210
|
`;
|
|
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
211
|
}
|
|
154
|
-
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/modes.ts
|
|
214
|
+
const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
|
|
215
|
+
/**
|
|
216
|
+
* Prepare VSCode isolated profile if --profile-sandbox is set.
|
|
217
|
+
*/
|
|
218
|
+
function setupVSCodeProfile(home) {
|
|
219
|
+
const dataDir = join(home, ".vscode-sandbox");
|
|
220
|
+
const globalExt = join(home, ".vscode", "extensions");
|
|
221
|
+
const localExt = join(dataDir, "extensions");
|
|
222
|
+
mkdirSync(dataDir, { recursive: true });
|
|
223
|
+
if (!existsSync(localExt) && existsSync(globalExt)) {
|
|
224
|
+
console.error("sandbox: copying extensions from global install...");
|
|
225
|
+
cpSync(globalExt, localExt, { recursive: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Build the command + args to run inside the sandbox for the given mode.
|
|
230
|
+
*/
|
|
231
|
+
function buildCommand(mode, workDirs, home, profileSandbox, execCmd) {
|
|
155
232
|
switch (mode) {
|
|
156
233
|
case "code": {
|
|
234
|
+
const dataDir = join(home, ".vscode-sandbox");
|
|
157
235
|
const args = ["--no-sandbox"];
|
|
158
236
|
if (profileSandbox) {
|
|
159
|
-
args.push("--user-data-dir", join(
|
|
160
|
-
args.push("--extensions-dir",
|
|
237
|
+
args.push("--user-data-dir", join(dataDir, "data"));
|
|
238
|
+
args.push("--extensions-dir", join(dataDir, "extensions"));
|
|
161
239
|
}
|
|
162
|
-
args.push(
|
|
240
|
+
args.push(...workDirs);
|
|
163
241
|
return {
|
|
164
242
|
bin: VSCODE_APP,
|
|
165
243
|
args
|
|
@@ -171,7 +249,7 @@ function buildCommand() {
|
|
|
171
249
|
};
|
|
172
250
|
case "claude": return {
|
|
173
251
|
bin: "claude",
|
|
174
|
-
args: [
|
|
252
|
+
args: []
|
|
175
253
|
};
|
|
176
254
|
case "exec": return {
|
|
177
255
|
bin: execCmd[0],
|
|
@@ -179,18 +257,72 @@ function buildCommand() {
|
|
|
179
257
|
};
|
|
180
258
|
}
|
|
181
259
|
}
|
|
182
|
-
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/index.ts
|
|
262
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
263
|
+
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
|
|
264
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
265
|
+
console.log(`bx ${VERSION}`);
|
|
266
|
+
process.exit(0);
|
|
267
|
+
}
|
|
268
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
269
|
+
console.log(`bx ${VERSION} — launch apps in a macOS sandbox
|
|
270
|
+
|
|
271
|
+
Usage:
|
|
272
|
+
bx [workdir...] VSCode (default)
|
|
273
|
+
bx code [workdir...] VSCode
|
|
274
|
+
bx term [workdir...] sandboxed login shell
|
|
275
|
+
bx claude [workdir...] Claude Code CLI
|
|
276
|
+
bx exec [workdir...] -- command [args...] arbitrary command
|
|
277
|
+
|
|
278
|
+
Options:
|
|
279
|
+
--verbose print the generated sandbox profile
|
|
280
|
+
--profile-sandbox use an isolated VSCode profile (code mode only)
|
|
281
|
+
-v, --version show version
|
|
282
|
+
-h, --help show this help
|
|
283
|
+
|
|
284
|
+
Configuration:
|
|
285
|
+
~/.bxallow extra allowed directories (one per line)
|
|
286
|
+
~/.bxignore extra blocked paths in $HOME (one per line)
|
|
287
|
+
<workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
|
|
288
|
+
|
|
289
|
+
https://github.com/holtwick/bx-mac`);
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
checkOwnSandbox();
|
|
293
|
+
checkVSCodeTerminal();
|
|
294
|
+
checkExternalSandbox();
|
|
295
|
+
const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
|
|
296
|
+
const HOME = process.env.HOME;
|
|
297
|
+
const WORK_DIRS = workArgs.map((a) => resolve(a));
|
|
298
|
+
checkWorkDirs(WORK_DIRS, HOME);
|
|
299
|
+
if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
|
|
300
|
+
const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
|
|
301
|
+
const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
|
|
302
|
+
const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
|
|
303
|
+
if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
|
|
304
|
+
const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
|
|
305
|
+
const profilePath = join("/tmp", `bx-${process.pid}.sb`);
|
|
306
|
+
writeFileSync(profilePath, profile);
|
|
307
|
+
const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
|
|
308
|
+
console.error(`sandbox: ${mode} mode, working directory: ${dirLabel}`);
|
|
309
|
+
if (verbose) {
|
|
310
|
+
console.error("\n--- Generated sandbox profile ---");
|
|
311
|
+
console.error(profile);
|
|
312
|
+
console.error("--- End of profile ---\n");
|
|
313
|
+
}
|
|
314
|
+
const cmd = buildCommand(mode, WORK_DIRS, HOME, profileSandbox, execCmd);
|
|
183
315
|
spawn("sandbox-exec", [
|
|
184
316
|
"-f",
|
|
185
317
|
profilePath,
|
|
186
318
|
"-D",
|
|
187
319
|
`HOME=${HOME}`,
|
|
188
320
|
"-D",
|
|
189
|
-
`WORK=${
|
|
321
|
+
`WORK=${WORK_DIRS[0]}`,
|
|
190
322
|
cmd.bin,
|
|
191
323
|
...cmd.args
|
|
192
324
|
], {
|
|
193
|
-
cwd:
|
|
325
|
+
cwd: WORK_DIRS[0],
|
|
194
326
|
stdio: "inherit",
|
|
195
327
|
env: {
|
|
196
328
|
...process.env,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bx-mac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,9 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "rolldown -c",
|
|
14
|
-
"
|
|
15
|
-
"prepublishOnly": "npm run build"
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"post:release": "./scripts/release.sh"
|
|
16
17
|
},
|
|
17
18
|
"keywords": [
|
|
18
19
|
"sandbox",
|
|
@@ -24,7 +25,9 @@
|
|
|
24
25
|
"author": "Dirk Holtwick",
|
|
25
26
|
"license": "MIT",
|
|
26
27
|
"devDependencies": {
|
|
27
|
-
"
|
|
28
|
+
"@types/node": "^25.5.0",
|
|
29
|
+
"rolldown": "^1.0.0-rc.12",
|
|
30
|
+
"vitest": "^4.1.2"
|
|
28
31
|
},
|
|
29
32
|
"engines": {
|
|
30
33
|
"node": ">=22"
|