bx-mac 0.1.5 → 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/README.md +73 -83
- package/dist/bx.js +223 -109
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
# bx
|
|
1
|
+
# 📦 bx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Put your AI in a box.** Launch VSCode, Claude Code, a terminal, or any command in a macOS sandbox — your tools can only see the project you're working on.
|
|
4
4
|
|
|
5
|
-
## Why?
|
|
5
|
+
## 🤔 Why?
|
|
6
6
|
|
|
7
|
-
AI-powered coding tools like Claude Code, Copilot, or Cline run with broad file system access
|
|
7
|
+
AI-powered coding tools like Claude Code, Copilot, or Cline run with **broad file system access**. A misguided tool call or hallucinated path could accidentally read your SSH keys, credentials, tax documents, or private photos.
|
|
8
8
|
|
|
9
9
|
**bx** wraps any application in a macOS sandbox (`sandbox-exec`) that blocks access to everything except the project directory you explicitly specify. No containers, no VMs, no setup — just one command.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
```bash
|
|
12
|
+
bx ~/work/my-project
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's it. 🎉 VSCode opens with full access to `~/work/my-project` and nothing else.
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
- Blocks access to sibling projects — only the directory you specify is accessible
|
|
15
|
-
- Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo` by default
|
|
16
|
-
- Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
|
|
17
|
-
- Generates sandbox rules dynamically based on your actual `$HOME` contents
|
|
18
|
-
- Supports `.bxignore` to hide secrets like `.env` files within a project
|
|
19
|
-
- Supports `~/.bxallow` to grant access to shared utility directories
|
|
17
|
+
## ✅ What it does
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
- 🔒 Blocks `~/Documents`, `~/Desktop`, `~/Downloads`, and all other personal folders
|
|
20
|
+
- 🚧 Blocks sibling projects — only the directory you specify is accessible
|
|
21
|
+
- 🛡️ Protects sensitive dotdirs like `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.cargo`
|
|
22
|
+
- ⚙️ Keeps VSCode, extensions, shell, Node.js, and other tooling fully functional
|
|
23
|
+
- 🔍 Generates sandbox rules dynamically based on your actual `$HOME` contents
|
|
24
|
+
- 📝 Supports `.bxignore` to hide secrets like `.env` files within a project
|
|
25
|
+
- 📂 Supports `~/.bxallow` to grant access to shared utility directories
|
|
26
|
+
|
|
27
|
+
## 🚫 What it doesn't do
|
|
22
28
|
|
|
23
29
|
- **No network restrictions** — API calls, git push/pull, npm install all work normally
|
|
24
30
|
- **No process isolation** — this is file-level sandboxing, not a container
|
|
25
31
|
- **No protection against root/sudo** — the sandbox applies to the user-level process
|
|
26
|
-
- **macOS only** — relies on `sandbox-exec`
|
|
27
|
-
- **Not a
|
|
28
|
-
|
|
29
|
-
## Requirements
|
|
30
|
-
|
|
31
|
-
- macOS (tested on Sequoia / macOS 15)
|
|
32
|
-
- Node.js >= 22
|
|
33
|
-
- Visual Studio Code installed in `/Applications` (for `code` mode)
|
|
32
|
+
- **macOS only** — relies on `sandbox-exec` (Apple-specific)
|
|
33
|
+
- **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
|
|
34
34
|
|
|
35
|
-
## Install
|
|
35
|
+
## 📥 Install
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# Homebrew
|
|
@@ -48,71 +48,65 @@ pnpm install && pnpm build
|
|
|
48
48
|
pnpm link -g
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
**Requirements:** macOS (tested on Sequoia 15), Node.js >= 22
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
# Launch VSCode with sandbox protection
|
|
55
|
-
bx ~/work/my-project
|
|
56
|
-
```
|
|
53
|
+
## 🚀 Modes
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
bx [workdir]
|
|
64
|
-
bx
|
|
65
|
-
bx term [workdir] # sandboxed login shell ($SHELL -l)
|
|
66
|
-
bx claude [workdir] # Claude Code CLI
|
|
67
|
-
bx exec [workdir] -- command [args...] # arbitrary command
|
|
68
|
-
```
|
|
55
|
+
| Command | What it launches |
|
|
56
|
+
|---|---|
|
|
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 |
|
|
69
62
|
|
|
70
63
|
If no directory is given, the current directory is used.
|
|
71
64
|
|
|
72
65
|
### Examples
|
|
73
66
|
|
|
74
67
|
```bash
|
|
75
|
-
#
|
|
68
|
+
# 🖥️ VSCode with sandbox protection
|
|
69
|
+
bx ~/work/my-project
|
|
70
|
+
|
|
71
|
+
# 💻 Work on a project in a sandboxed terminal
|
|
76
72
|
bx term ~/work/my-project
|
|
77
73
|
|
|
78
|
-
# Let Claude Code work on a project
|
|
74
|
+
# 🤖 Let Claude Code work on a project — nothing else visible
|
|
79
75
|
bx claude ~/work/my-project
|
|
80
76
|
|
|
81
|
-
# Run a script in a sandbox
|
|
77
|
+
# ⚡ Run a script in a sandbox
|
|
82
78
|
bx exec ~/work/my-project -- python train.py
|
|
83
79
|
|
|
84
|
-
#
|
|
80
|
+
# 🔍 See the generated sandbox profile
|
|
85
81
|
bx --verbose ~/work/my-project
|
|
86
82
|
```
|
|
87
83
|
|
|
88
|
-
## Options
|
|
84
|
+
## ⚙️ Options
|
|
89
85
|
|
|
90
86
|
| Option | Description |
|
|
91
87
|
|---|---|
|
|
92
88
|
| `--verbose` | Print the generated sandbox profile to stderr |
|
|
93
|
-
| `--profile-sandbox` | Use an isolated VSCode profile (separate extensions
|
|
89
|
+
| `--profile-sandbox` | Use an isolated VSCode profile (separate extensions/settings, `code` mode only) |
|
|
94
90
|
|
|
95
|
-
## Configuration
|
|
91
|
+
## 📝 Configuration
|
|
96
92
|
|
|
97
|
-
bx uses three optional config files
|
|
93
|
+
bx uses three optional config files — one entry per line, `#` for comments.
|
|
98
94
|
|
|
99
95
|
### `~/.bxallow`
|
|
100
96
|
|
|
101
|
-
Allow extra directories beyond the working directory. Paths
|
|
97
|
+
Allow extra directories beyond the working directory. Paths relative to `$HOME`.
|
|
102
98
|
|
|
103
99
|
```gitignore
|
|
104
100
|
# Shared shell scripts and utilities
|
|
105
101
|
work/bin
|
|
106
|
-
# Shared libraries used across projects
|
|
107
102
|
shared/libs
|
|
108
103
|
```
|
|
109
104
|
|
|
110
105
|
### `~/.bxignore`
|
|
111
106
|
|
|
112
|
-
Block additional dotdirs or files in your home. Paths
|
|
107
|
+
Block additional dotdirs or files in your home. Paths relative to `$HOME`.
|
|
113
108
|
|
|
114
109
|
```gitignore
|
|
115
|
-
# Cloud provider credentials
|
|
116
110
|
.aws
|
|
117
111
|
.azure
|
|
118
112
|
.kube
|
|
@@ -121,74 +115,70 @@ Block additional dotdirs or files in your home. Paths are relative to `$HOME`.
|
|
|
121
115
|
|
|
122
116
|
These are blocked **in addition** to the built-in protected list:
|
|
123
117
|
|
|
124
|
-
> `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
|
|
118
|
+
> 🔒 `.Trash` `.ssh` `.gnupg` `.docker` `.zsh_sessions` `.cargo` `.gradle` `.gem`
|
|
125
119
|
|
|
126
120
|
### `<project>/.bxignore`
|
|
127
121
|
|
|
128
|
-
Block paths within the working directory
|
|
122
|
+
Block paths within the working directory. Supports glob patterns.
|
|
129
123
|
|
|
130
124
|
```gitignore
|
|
131
|
-
# Environment files with secrets
|
|
132
125
|
.env
|
|
133
126
|
.env.*
|
|
134
|
-
|
|
135
|
-
# Credentials and keys
|
|
136
127
|
secrets/
|
|
137
128
|
**/*.pem
|
|
138
129
|
**/*.key
|
|
139
130
|
```
|
|
140
131
|
|
|
141
|
-
## How it works
|
|
132
|
+
## 🔧 How it works
|
|
142
133
|
|
|
143
134
|
bx generates a macOS sandbox profile at launch time:
|
|
144
135
|
|
|
145
136
|
1. **Scan** `$HOME` for non-hidden directories
|
|
146
|
-
2. **Block** each one individually with
|
|
147
|
-
3. **Skip** the working directory, `~/Library`, dotfiles, and
|
|
148
|
-
4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules
|
|
137
|
+
2. **Block** each one individually with `(deny file* (subpath ...))`
|
|
138
|
+
3. **Skip** the working directory, `~/Library`, dotfiles, and `~/.bxallow` paths
|
|
139
|
+
4. **Descend** into parent directories of allowed paths to block only siblings (because SBPL deny rules always override allow rules)
|
|
149
140
|
5. **Append** deny rules for protected dotdirs and `.bxignore` entries
|
|
150
|
-
6. **Write** the profile to `/tmp`, launch
|
|
141
|
+
6. **Write** the profile to `/tmp`, launch the app via `sandbox-exec`, clean up on exit
|
|
151
142
|
|
|
152
143
|
### Why not a simple deny-all + allow?
|
|
153
144
|
|
|
154
|
-
Apple's
|
|
145
|
+
Apple's SBPL has a critical quirk: **`deny` always wins over `allow`**, regardless of rule order:
|
|
155
146
|
|
|
156
147
|
```scheme
|
|
157
|
-
;; Does NOT work — the deny still blocks myproject
|
|
148
|
+
;; ❌ Does NOT work — the deny still blocks myproject
|
|
158
149
|
(deny file* (subpath "/Users/me/work"))
|
|
159
150
|
(allow file* (subpath "/Users/me/work/myproject"))
|
|
160
151
|
```
|
|
161
152
|
|
|
162
|
-
Additionally, a broad `(deny file* (subpath HOME))` breaks `kqueue`/FSEvents file watchers and SQLite
|
|
163
|
-
|
|
164
|
-
bx avoids both issues by **never denying a parent of an allowed path**. Instead, it walks the directory tree and denies only the specific siblings that should be blocked.
|
|
153
|
+
Additionally, a broad `(deny file* (subpath HOME))` breaks `kqueue`/FSEvents file watchers and SQLite locks, causing VSCode errors.
|
|
165
154
|
|
|
166
|
-
|
|
155
|
+
bx avoids both issues by **never denying a parent of an allowed path** — it walks the directory tree and blocks only the specific siblings.
|
|
167
156
|
|
|
168
|
-
|
|
157
|
+
## 🛡️ Safety checks
|
|
169
158
|
|
|
170
|
-
|
|
159
|
+
bx detects and prevents problematic scenarios:
|
|
171
160
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
ls ~/work/other-project/ # Should fail
|
|
176
|
-
```
|
|
161
|
+
- **🔄 Sandbox nesting:** If `CODEBOX_SANDBOX=1` is set (auto-propagated), bx refuses to start — nested sandboxes cause silent failures.
|
|
162
|
+
- **🔍 Unknown sandbox:** On startup, bx probes `~/Documents`, `~/Desktop`, `~/Downloads`. If any return `EPERM`, another sandbox is active — bx aborts.
|
|
163
|
+
- **⚠️ VSCode terminal:** If `VSCODE_PID` is set, bx warns that it will launch a *new* instance, not sandbox the current one.
|
|
177
164
|
|
|
178
|
-
##
|
|
165
|
+
## 💡 Tips
|
|
179
166
|
|
|
180
|
-
|
|
167
|
+
**Verify it works** — try reading a blocked file from the sandboxed terminal:
|
|
181
168
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
169
|
+
```bash
|
|
170
|
+
cat ~/Documents/something.txt # ❌ Operation not permitted
|
|
171
|
+
cat ~/Desktop/file.txt # ❌ Operation not permitted
|
|
172
|
+
ls ~/work/other-project/ # ❌ Operation not permitted
|
|
173
|
+
cat ./src/index.ts # ✅ Works!
|
|
174
|
+
```
|
|
185
175
|
|
|
186
|
-
## Known limitations
|
|
176
|
+
## ⚠️ Known limitations
|
|
187
177
|
|
|
188
|
-
- **File watcher warnings:** VSCode may log `EPERM`
|
|
189
|
-
- **SQLite warnings:** `state.vscdb` errors may appear in logs
|
|
190
|
-
- **`sandbox-exec` is undocumented:** Apple
|
|
178
|
+
- **File watcher warnings:** VSCode may log `EPERM` for `fs.watch()` on some paths — cosmetic only
|
|
179
|
+
- **SQLite warnings:** `state.vscdb` errors may appear in logs — extensions still work
|
|
180
|
+
- **`sandbox-exec` is undocumented:** Apple could change behavior with OS updates
|
|
191
181
|
|
|
192
|
-
## License
|
|
182
|
+
## 📄 License
|
|
193
183
|
|
|
194
184
|
MIT — see [LICENSE](LICENSE).
|
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": {
|