bx-mac 1.1.0 → 1.2.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 +3 -0
- package/dist/bx.js +46 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ bx ~/work/my-project ~/work/shared-lib
|
|
|
44
44
|
- **No protection against root/sudo** — the sandbox applies to the user-level process
|
|
45
45
|
- **macOS only** — relies on `sandbox-exec` (Apple-specific)
|
|
46
46
|
- **Not dynamic** — the sandbox profile is a snapshot of `$HOME` at launch time; directories or files created later are **not** automatically blocked
|
|
47
|
+
- **File names visible** — blocked files cannot be read or written, but their names still appear in directory listings (a kernel-level `readdir` constraint, same as `chmod 000`)
|
|
47
48
|
- **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
|
|
48
49
|
|
|
49
50
|
## 📥 Install
|
|
@@ -380,6 +381,8 @@ These are great when available, but they only protect within their own tool. bx
|
|
|
380
381
|
## 🔗 Alternatives
|
|
381
382
|
|
|
382
383
|
- [Agent Safehouse](https://agent-safehouse.dev/) — macOS kernel-level sandboxing for LLM coding agents via `sandbox-exec`. Deny-first model that blocks write access outside the project directory.
|
|
384
|
+
- **Docker / VMs** — for stronger isolation, run AI tools in a virtualized environment (containers, VMs). Full process and network isolation at the cost of setup overhead.
|
|
385
|
+
- **Web sandboxes** — browser-based approaches for running AI agents. See Simon Willison's [Living dangerously with Claude](https://simonwillison.net/2025/Oct/22/living-dangerously-with-claude/) for an overview.
|
|
383
386
|
|
|
384
387
|
## 💛 Sponsor
|
|
385
388
|
|
package/dist/bx.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { accessSync, closeSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
@@ -811,12 +811,15 @@ function parsePassPaths(val) {
|
|
|
811
811
|
if (typeof val === "number" && Number.isInteger(val) && val > 0) return val;
|
|
812
812
|
if (Array.isArray(val)) {
|
|
813
813
|
const paths = val.filter((a) => typeof a === "string");
|
|
814
|
+
if (val.length !== paths.length) console.error(fmt.warn("non-string items in array config were ignored"));
|
|
814
815
|
if (paths.length > 0) return paths;
|
|
816
|
+
return false;
|
|
815
817
|
}
|
|
816
818
|
}
|
|
817
819
|
function parseStringArray(val) {
|
|
818
820
|
if (Array.isArray(val)) {
|
|
819
821
|
const items = val.filter((a) => typeof a === "string");
|
|
822
|
+
if (val.length !== items.length) console.error(fmt.warn("non-string items in array config were ignored"));
|
|
820
823
|
if (items.length > 0) return items;
|
|
821
824
|
}
|
|
822
825
|
}
|
|
@@ -1084,9 +1087,12 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
|
|
|
1084
1087
|
} catch {
|
|
1085
1088
|
continue;
|
|
1086
1089
|
}
|
|
1087
|
-
if (!isDir) continue;
|
|
1088
1090
|
if (parentDir === home && name === "Library") continue;
|
|
1089
1091
|
if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
|
|
1092
|
+
if (!isDir) {
|
|
1093
|
+
blocked.push(fullPath);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1090
1096
|
const status = isAllowedOrAncestor(fullPath, allowedDirs);
|
|
1091
1097
|
if (status === "allowed") continue;
|
|
1092
1098
|
if (status === "ancestor") {
|
|
@@ -1122,7 +1128,7 @@ function collectIgnoredPaths(home, workDirs) {
|
|
|
1122
1128
|
return ignored;
|
|
1123
1129
|
}
|
|
1124
1130
|
function sbplEscape(path) {
|
|
1125
|
-
return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
1131
|
+
return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
1126
1132
|
}
|
|
1127
1133
|
function sbplSubpath(path) {
|
|
1128
1134
|
return ` (subpath "${sbplEscape(path)}")`;
|
|
@@ -1157,7 +1163,7 @@ function collectSystemDenyPaths(home) {
|
|
|
1157
1163
|
return paths;
|
|
1158
1164
|
}
|
|
1159
1165
|
function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
|
|
1160
|
-
const blockedRules = sbplDenyBlock("Blocked
|
|
1166
|
+
const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
|
|
1161
1167
|
const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
|
|
1162
1168
|
const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
|
|
1163
1169
|
const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
|
|
@@ -1405,10 +1411,20 @@ function buildCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
|
|
|
1405
1411
|
}
|
|
1406
1412
|
function buildBuiltinCommand(mode, appArgs) {
|
|
1407
1413
|
switch (mode) {
|
|
1408
|
-
case "term":
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1414
|
+
case "term": {
|
|
1415
|
+
const shell = process$1.env.SHELL ?? "/bin/zsh";
|
|
1416
|
+
if (!existsSync(shell)) {
|
|
1417
|
+
console.error(fmt.warn(`shell not found: ${shell}, falling back to /bin/zsh`));
|
|
1418
|
+
return {
|
|
1419
|
+
bin: "/bin/zsh",
|
|
1420
|
+
args: ["-l"]
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
bin: shell,
|
|
1425
|
+
args: ["-l"]
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1412
1428
|
case "claude": return {
|
|
1413
1429
|
bin: "claude",
|
|
1414
1430
|
args: []
|
|
@@ -1594,7 +1610,7 @@ function kindIcon(kind) {
|
|
|
1594
1610
|
default: return `${RED}✖${RESET}`;
|
|
1595
1611
|
}
|
|
1596
1612
|
}
|
|
1597
|
-
function insertPath(root,
|
|
1613
|
+
function insertPath(root, absPath, kind, isDir) {
|
|
1598
1614
|
const parts = absPath.split("/").filter(Boolean);
|
|
1599
1615
|
let node = root;
|
|
1600
1616
|
for (const part of parts) {
|
|
@@ -1609,7 +1625,6 @@ function insertPath(root, homeParts, absPath, kind, isDir) {
|
|
|
1609
1625
|
* Intermediate directories on the home path are kept as navigation context. */
|
|
1610
1626
|
function pruneTree(node, currentParts, homeParts, depth) {
|
|
1611
1627
|
if (node.kind) return true;
|
|
1612
|
-
depth < homeParts.length && (currentParts[depth], homeParts[depth]);
|
|
1613
1628
|
for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
|
|
1614
1629
|
return node.children.size > 0;
|
|
1615
1630
|
}
|
|
@@ -1617,7 +1632,7 @@ function isDirectory(path) {
|
|
|
1617
1632
|
try {
|
|
1618
1633
|
return statSync(path).isDirectory();
|
|
1619
1634
|
} catch {
|
|
1620
|
-
return
|
|
1635
|
+
return true;
|
|
1621
1636
|
}
|
|
1622
1637
|
}
|
|
1623
1638
|
function printNode(node, prefix) {
|
|
@@ -1637,11 +1652,11 @@ function printNode(node, prefix) {
|
|
|
1637
1652
|
function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
|
|
1638
1653
|
const root = { children: /* @__PURE__ */ new Map() };
|
|
1639
1654
|
const homeParts = home.split("/").filter(Boolean);
|
|
1640
|
-
for (const dir of blockedDirs) insertPath(root,
|
|
1641
|
-
for (const path of ignoredPaths) insertPath(root,
|
|
1642
|
-
for (const dir of readOnlyDirs) insertPath(root,
|
|
1643
|
-
for (const dir of workDirs) insertPath(root,
|
|
1644
|
-
for (const dir of systemDenyPaths) insertPath(root,
|
|
1655
|
+
for (const dir of blockedDirs) insertPath(root, dir, "blocked", isDirectory(dir));
|
|
1656
|
+
for (const path of ignoredPaths) insertPath(root, path, "ignored", isDirectory(path));
|
|
1657
|
+
for (const dir of readOnlyDirs) insertPath(root, dir, "read-only", true);
|
|
1658
|
+
for (const dir of workDirs) insertPath(root, dir, "workdir", true);
|
|
1659
|
+
for (const dir of systemDenyPaths) insertPath(root, dir, "blocked", true);
|
|
1645
1660
|
pruneTree(root, [], homeParts, 0);
|
|
1646
1661
|
console.log(`\n${CYAN}/${RESET}`);
|
|
1647
1662
|
printNode(root, "");
|
|
@@ -1649,7 +1664,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
|
|
|
1649
1664
|
}
|
|
1650
1665
|
//#endregion
|
|
1651
1666
|
//#region src/index.ts
|
|
1652
|
-
const VERSION = "1.
|
|
1667
|
+
const VERSION = "1.2.0";
|
|
1653
1668
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1654
1669
|
if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
|
|
1655
1670
|
console.log(`bx ${VERSION}`);
|
|
@@ -1675,6 +1690,10 @@ async function main() {
|
|
|
1675
1690
|
const { mode, workArgs, verbose, dry, vscodeUser: vscodeUserFlag, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
|
|
1676
1691
|
const app = apps[mode];
|
|
1677
1692
|
const workDirs = expandGlobs(implicit && app?.paths?.length ? app.paths : workArgs, HOME).map((a) => realpathSync(resolve(a)));
|
|
1693
|
+
if (workDirs.length === 0) {
|
|
1694
|
+
console.error(`\n${fmt.error("no matching working directories found")}\n`);
|
|
1695
|
+
process$1.exit(1);
|
|
1696
|
+
}
|
|
1678
1697
|
if (implicit && !app?.paths?.length) {
|
|
1679
1698
|
if (workDirs.some((d) => d === HOME)) {
|
|
1680
1699
|
console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
|
|
@@ -1755,7 +1774,12 @@ async function main() {
|
|
|
1755
1774
|
CODEBOX_SANDBOX: "1"
|
|
1756
1775
|
}
|
|
1757
1776
|
});
|
|
1777
|
+
child.on("error", (err) => {
|
|
1778
|
+
console.error(fmt.error(`failed to start sandbox: ${err.message}`));
|
|
1779
|
+
process$1.exit(1);
|
|
1780
|
+
});
|
|
1758
1781
|
child.unref();
|
|
1782
|
+
closeSync(logFd);
|
|
1759
1783
|
bringAppToFront(mode, apps);
|
|
1760
1784
|
console.error(fmt.info(`running in background (pid ${child.pid})`));
|
|
1761
1785
|
console.error(fmt.detail(`log: ${logPath}`));
|
|
@@ -1779,7 +1803,6 @@ async function main() {
|
|
|
1779
1803
|
CODEBOX_SANDBOX: "1"
|
|
1780
1804
|
}
|
|
1781
1805
|
});
|
|
1782
|
-
bringAppToFront(mode, apps);
|
|
1783
1806
|
const cleanup = () => {
|
|
1784
1807
|
try {
|
|
1785
1808
|
rmSync(tmpDir, {
|
|
@@ -1789,6 +1812,11 @@ async function main() {
|
|
|
1789
1812
|
} catch {}
|
|
1790
1813
|
};
|
|
1791
1814
|
process$1.on("exit", cleanup);
|
|
1815
|
+
child.on("error", (err) => {
|
|
1816
|
+
console.error(fmt.error(`failed to start sandbox: ${err.message}`));
|
|
1817
|
+
process$1.exit(1);
|
|
1818
|
+
});
|
|
1819
|
+
bringAppToFront(mode, apps);
|
|
1792
1820
|
child.on("close", (code) => {
|
|
1793
1821
|
process$1.exit(code ?? 0);
|
|
1794
1822
|
});
|