getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { log } from './log.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The OS sandbox for what an API-key Boton with Workspace access does itself: every command, file read and file write
|
|
9
|
+
* of its tools runs in bubblewrap (Linux user namespaces), never in the server's own process. What it may affect is its
|
|
10
|
+
* workspace (and the shared folder), and nothing outside them:
|
|
11
|
+
*
|
|
12
|
+
* - Files: its root is an empty tmpfs, read-only. Of the host it sees only an allowlist (SYSTEM_TREES: /usr, /etc,
|
|
13
|
+
* /opt and the like, then the folders on its PATH and Node's own install), each through a read-only overlay, and its
|
|
14
|
+
* workspace and the shared folder, which are the only places it can write. No home folder (but the path down to the
|
|
15
|
+
* workspace), no data folder, no other workspace, no /var, /srv, /mnt, /media, /sys; an empty /tmp, /var/tmp and
|
|
16
|
+
* /run. The paths isolation.ts lists are hidden too where they fall inside what it sees. The settings folders CLIs
|
|
17
|
+
* load from the workspace (.claude) and, in a sandboxed turn, the instruction files later turns follow are mounted
|
|
18
|
+
* read-only on top. A hard link can't cross into a writable folder (each mount is its own).
|
|
19
|
+
* - Named pipes and sockets on the host: an overlay shows a FIFO or a socket file as the overlay's own inode, so
|
|
20
|
+
* opening, writing, reading or connecting to one through it never reaches the program that made it, wherever it is
|
|
21
|
+
* in what the sandbox sees (sandboxProblem() proves this on the machine with a pipe of its own before anything runs).
|
|
22
|
+
* Device files there don't open either: a mount made in a user namespace is nodev.
|
|
23
|
+
* - Unix sockets at all: a seccomp filter (FILTER below) refuses socket() for every family but IPv4, IPv6 and netlink,
|
|
24
|
+
* all of its own network namespace, so no AF_UNIX socket can be made (an abstract one neither). Only
|
|
25
|
+
* socketpair(AF_UNIX, SOCK_STREAM) is let through: a connected pair inside the sandbox (as Node and Python use for
|
|
26
|
+
* their child processes), which can't be pointed at another socket. io_uring (which makes sockets without socket())
|
|
27
|
+
* and the kernel keyrings (the server's user's own) are refused too. Programs that need a Unix socket of their own
|
|
28
|
+
* (a database's local socket, a browser) don't run here; Full computer access runs them.
|
|
29
|
+
* - Network: its own network namespace, with only its own loopback: the host's loopback, its private networks and
|
|
30
|
+
* the internet aren't there at all (the filter only narrows what it may make inside that namespace).
|
|
31
|
+
* - Other programs: no other process to see or signal (its own /proc), no SysV or POSIX IPC, no capabilities, only the
|
|
32
|
+
* environment it is given (never the server's own: no API key), no file descriptor of the server's but its stdin,
|
|
33
|
+
* stdout and stderr. Node opens every descriptor of its own close-on-exec, and marks those it was started with only
|
|
34
|
+
* up to the first one missing after 15; one past that (a CI runner's, a terminal's) would pass through bubblewrap
|
|
35
|
+
* into the command: a folder there opens the host's files past every mount, a pipe or a socket reaches the program
|
|
36
|
+
* at its other end. So each one is closed before a sandboxed command starts, and nothing starts while one is left
|
|
37
|
+
* (closeInherited()); the check proves inside that none arrives.
|
|
38
|
+
* - Its workspace is the owner's folder too: a file, or a named pipe, a program of the owner's watches there reaches
|
|
39
|
+
* that program, as anything the Boton writes there does. That is the workspace's purpose, not a way out of it.
|
|
40
|
+
*
|
|
41
|
+
* Without bubblewrap (0.11 or newer, for overlays), or where it can't make the namespaces, the overlays or load the
|
|
42
|
+
* filter (not Linux, an architecture FILTER isn't built for, a container that forbids them), or where the check's pipe
|
|
43
|
+
* gets anything, nothing runs for a Workspace Boton: sandboxProblem() says why, and the turn fails with that before
|
|
44
|
+
* its first request. Nothing falls back to a weaker sandbox. Full computer access runs the same tools as the server's
|
|
45
|
+
* user, outside any sandbox, as the owner chose for the Boton.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface SandboxPlan {
|
|
49
|
+
/** Absolute: the Boton's workspace, where commands start. */
|
|
50
|
+
cwd: string;
|
|
51
|
+
/** Writable: the workspace and the shared folder (none in a sandboxed turn but the workspace). */
|
|
52
|
+
writable: string[];
|
|
53
|
+
/** Hidden: each one that exists becomes an empty folder or an empty file. */
|
|
54
|
+
hidden: string[];
|
|
55
|
+
/** Read-only on top of a writable folder (created first when a folder that doesn't exist yet). */
|
|
56
|
+
readOnly: { path: string; dir: boolean }[];
|
|
57
|
+
env: Record<string, string>;
|
|
58
|
+
/**
|
|
59
|
+
* This computer's network instead of a namespace of its own (a local connector's, connector-launch.ts), with the
|
|
60
|
+
* resolver's file where /etc/resolv.conf points outside the view. The socket filter stays on.
|
|
61
|
+
*/
|
|
62
|
+
network?: boolean;
|
|
63
|
+
/** More host folders to show read-only, as the view shows a folder on PATH (never one it would leave out). */
|
|
64
|
+
trees?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Folders that must not be seen by any path: a tree the view shows that is one of them, or lies inside one, through
|
|
67
|
+
* another mount of the same filesystem (a bind mount shows a folder at a second path) is left out, and where it holds
|
|
68
|
+
* one that way, that place in it is hidden. Without the mount table, nothing is shown.
|
|
69
|
+
*/
|
|
70
|
+
apart?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let BWRAP = 'bwrap';
|
|
74
|
+
const CHECK_TIMEOUT_MS = 10_000;
|
|
75
|
+
/** How long a passed check stands (while its program stays the same file), and a failed one. */
|
|
76
|
+
const PASSED_MS = 10 * 60_000;
|
|
77
|
+
const FAILED_MS = 60_000;
|
|
78
|
+
const MAX_CHECKS = 8;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The bubblewrap a check ran, as the file it resolved to: its real path, and what says it is still that file (device,
|
|
82
|
+
* inode, size, modification and change times, mode, owner). Resolved again, synchronously, just before every sandboxed
|
|
83
|
+
* spawn, which then runs that real path, not a name looked up again: replaced, moved, re-linked or shadowed by
|
|
84
|
+
* another on PATH, and it is checked again before anything runs. What remains is the moment between that last stat and
|
|
85
|
+
* the exec; anyone who can swap the file in it can already change the server's own programs.
|
|
86
|
+
*/
|
|
87
|
+
interface Exe {
|
|
88
|
+
path: string;
|
|
89
|
+
id: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Each check by what it ran under: the bubblewrap asked for, the PATH it was found on, the filter it loaded. */
|
|
93
|
+
const checks = new Map<string, { at: number; problem: string | null; exe: Exe | null }>();
|
|
94
|
+
|
|
95
|
+
export function resetSandboxChecks(): void { checks.clear(); }
|
|
96
|
+
|
|
97
|
+
/** Tests only (set in code, never from a setting): another bubblewrap, e.g. one that isn't there. */
|
|
98
|
+
export function useSandboxBinary(bin: string): void {
|
|
99
|
+
BWRAP = bin;
|
|
100
|
+
checks.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const searchPath = () => process.env.PATH ?? '/usr/bin:/bin';
|
|
104
|
+
|
|
105
|
+
/** `bin` as the file it runs: a path as given, a name the first executable file of it on PATH; null when there is none. */
|
|
106
|
+
function resolveExe(bin: string, path = searchPath()): Exe | null {
|
|
107
|
+
const candidates = bin.includes('/') ? [resolve(bin)] : path.split(':').filter((d) => isAbsolute(d)).map((d) => join(d, bin));
|
|
108
|
+
for (const c of candidates) {
|
|
109
|
+
try {
|
|
110
|
+
accessSync(c, constants.X_OK);
|
|
111
|
+
const file = realpathSync(c);
|
|
112
|
+
const st = statSync(file);
|
|
113
|
+
if (!st.isFile()) continue;
|
|
114
|
+
return { path: file, id: [st.dev, st.ino, st.size, st.mtimeMs, st.ctimeMs, st.mode, st.uid].join(':') };
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const keyOf = (bin: string, filter: Buffer | null) => `${bin}\0${searchPath()}\0${process.arch}\0${filter ? filter.toString('hex') : ''}`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The bubblewrap to spawn for a sandboxed command: the file the last passed check ran, when it is still that file,
|
|
124
|
+
* found the same way; else null (and sandboxProblem() checks again).
|
|
125
|
+
*/
|
|
126
|
+
function verifiedExe(bin: string, filter: Buffer): string | null {
|
|
127
|
+
const c = checks.get(keyOf(bin, filter));
|
|
128
|
+
if (!c || c.problem !== null || !c.exe || Date.now() - c.at >= PASSED_MS) return null;
|
|
129
|
+
const now = resolveExe(bin);
|
|
130
|
+
return now && now.path === c.exe.path && now.id === c.exe.id ? now.path : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The descriptor bubblewrap reads the seccomp filter from (`--seccomp 3`): the child's fourth stdio slot. */
|
|
134
|
+
const FILTER_FD = 3;
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* The seccomp filter, as classic BPF over struct seccomp_data { int nr; u32 arch; u64 ip; u64 args[6] } (little-endian
|
|
138
|
+
* on both architectures, so an argument's low 32 bits are at 16 + 8 * i, and the kernel reads these int arguments as
|
|
139
|
+
* those bits). Anything not named is allowed: the namespaces and the read-only mounts do the rest.
|
|
140
|
+
*/
|
|
141
|
+
const ARCHES = {
|
|
142
|
+
x64: { audit: 0xc000003e, x32: true, nr: { socket: 41, socketpair: 53, denied: [425, 426, 427, 248, 249, 250] } },
|
|
143
|
+
arm64: { audit: 0xc00000b7, x32: false, nr: { socket: 198, socketpair: 199, denied: [425, 426, 427, 217, 218, 219] } },
|
|
144
|
+
} as const;
|
|
145
|
+
// denied: io_uring_setup, io_uring_enter, io_uring_register; add_key, request_key, keyctl (in each table's order).
|
|
146
|
+
const AF_UNIX = 1;
|
|
147
|
+
const AF_INET = 2;
|
|
148
|
+
const AF_INET6 = 10;
|
|
149
|
+
const AF_NETLINK = 16;
|
|
150
|
+
const SOCK_STREAM = 1;
|
|
151
|
+
const SOCK_TYPE_MASK = 0xf;
|
|
152
|
+
const EPERM = 1;
|
|
153
|
+
const RET_ALLOW = 0x7fff0000;
|
|
154
|
+
const RET_ERRNO = 0x00050000;
|
|
155
|
+
const RET_KILL_PROCESS = 0x80000000;
|
|
156
|
+
|
|
157
|
+
type Insn = { code: number; jt?: string | number; jf?: string | number; k: number } | { label: string };
|
|
158
|
+
|
|
159
|
+
/** The filter for this machine's architecture, or null when it has none (then Workspace tools don't run). */
|
|
160
|
+
export function seccompFilter(arch: string = process.arch): Buffer | null {
|
|
161
|
+
const a = (ARCHES as Record<string, (typeof ARCHES)[keyof typeof ARCHES]>)[arch];
|
|
162
|
+
if (!a) return null;
|
|
163
|
+
const ld = (off: number): Insn => ({ code: 0x20, k: off }); // BPF_LD | BPF_W | BPF_ABS
|
|
164
|
+
const jeq = (k: number, jt: string | number, jf: string | number = 0): Insn => ({ code: 0x15, k, jt, jf }); // BPF_JMP | BPF_JEQ | BPF_K
|
|
165
|
+
const jge = (k: number, jt: string | number, jf: string | number = 0): Insn => ({ code: 0x35, k, jt, jf }); // BPF_JMP | BPF_JGE | BPF_K
|
|
166
|
+
const and = (k: number): Insn => ({ code: 0x54, k }); // BPF_ALU | BPF_AND | BPF_K
|
|
167
|
+
const ret = (k: number): Insn => ({ code: 0x06, k }); // BPF_RET | BPF_K
|
|
168
|
+
const prog: Insn[] = [
|
|
169
|
+
ld(4),
|
|
170
|
+
// Another ABI (32-bit compat) would bypass the numbers below: such a process is killed.
|
|
171
|
+
jeq(a.audit, 0, 'kill'),
|
|
172
|
+
ld(0),
|
|
173
|
+
...(a.x32 ? [jge(0x40000000, 'deny')] : []),
|
|
174
|
+
jeq(a.nr.socket, 'socket'),
|
|
175
|
+
jeq(a.nr.socketpair, 'socketpair'),
|
|
176
|
+
...a.nr.denied.map((nr) => jeq(nr, 'deny')),
|
|
177
|
+
ret(RET_ALLOW),
|
|
178
|
+
{ label: 'socket' },
|
|
179
|
+
ld(16),
|
|
180
|
+
jeq(AF_INET, 'allow'),
|
|
181
|
+
jeq(AF_INET6, 'allow'),
|
|
182
|
+
jeq(AF_NETLINK, 'allow'),
|
|
183
|
+
ret(RET_ERRNO | EPERM),
|
|
184
|
+
{ label: 'socketpair' },
|
|
185
|
+
ld(16),
|
|
186
|
+
jeq(AF_UNIX, 0, 'deny'),
|
|
187
|
+
ld(24),
|
|
188
|
+
and(SOCK_TYPE_MASK),
|
|
189
|
+
jeq(SOCK_STREAM, 'allow', 'deny'),
|
|
190
|
+
{ label: 'allow' },
|
|
191
|
+
ret(RET_ALLOW),
|
|
192
|
+
{ label: 'deny' },
|
|
193
|
+
ret(RET_ERRNO | EPERM),
|
|
194
|
+
{ label: 'kill' },
|
|
195
|
+
ret(RET_KILL_PROCESS),
|
|
196
|
+
];
|
|
197
|
+
const at = new Map<string, number>();
|
|
198
|
+
const code: Exclude<Insn, { label: string }>[] = [];
|
|
199
|
+
for (const i of prog) {
|
|
200
|
+
if ('label' in i) at.set(i.label, code.length);
|
|
201
|
+
else code.push(i);
|
|
202
|
+
}
|
|
203
|
+
const out = Buffer.alloc(code.length * 8);
|
|
204
|
+
code.forEach((i, n) => {
|
|
205
|
+
// A jump is relative to the next instruction, forward only.
|
|
206
|
+
const off = (t: string | number | undefined) => {
|
|
207
|
+
if (t === undefined || typeof t === 'number') return t ?? 0;
|
|
208
|
+
const d = at.get(t)! - n - 1;
|
|
209
|
+
if (d < 0 || d > 255) throw new Error(`bad jump to ${t}`);
|
|
210
|
+
return d;
|
|
211
|
+
};
|
|
212
|
+
out.writeUInt16LE(i.code, n * 8);
|
|
213
|
+
out.writeUInt8(off(i.jt), n * 8 + 2);
|
|
214
|
+
out.writeUInt8(off(i.jf), n * 8 + 3);
|
|
215
|
+
out.writeUInt32LE(i.k >>> 0, n * 8 + 4);
|
|
216
|
+
});
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The check, inside a sandbox with the real view: the filter on (seccomp mode 2) with no new privileges, and the probe's
|
|
222
|
+
* named pipe there, opened (read and write, so it never waits) and written to, and no descriptor of the server's open
|
|
223
|
+
* there. Exit 3 to 6 says which failed.
|
|
224
|
+
*/
|
|
225
|
+
const PROBE_AT = '/botons-sandbox-probe';
|
|
226
|
+
// Of the descriptors open inside, only stdin, stdout, stderr and the one the listing itself opens.
|
|
227
|
+
const CHECK_SCRIPT = `f=; n=; while read -r k v; do case "$k" in Seccomp:) f=$v;; NoNewPrivs:) n=$v;; esac; done < /proc/self/status; [ "$f" = 2 ] && [ "$n" = 1 ] || exit 3; d=0; for p in /proc/self/fd/*; do case "\${p##*/}" in 0|1|2) ;; *) d=$((d + 1));; esac; done; [ "$d" -le 1 ] || exit 6; [ -p ${PROBE_AT}/probe.fifo ] || exit 4; exec 3<>${PROBE_AT}/probe.fifo && echo leaked >&3 || exit 5`;
|
|
228
|
+
const CHECK_FAILED: Record<number, string> = {
|
|
229
|
+
3: 'its socket filter was not on inside',
|
|
230
|
+
4: 'its read-only view showed nothing',
|
|
231
|
+
5: 'its read-only view could not be opened',
|
|
232
|
+
6: 'a file descriptor of the server’s, besides stdin, stdout and stderr, was open inside',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const O_CLOEXEC = 0o2000000;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The descriptors a program this process starts would get besides its stdin, stdout and stderr: each one open without
|
|
239
|
+
* close-on-exec. Node and libuv open all of their own close-on-exec (the IPC channel too), and at startup mark those the
|
|
240
|
+
* server was started with up to the first one missing after 15 (uv_disable_stdio_inheritance), so each of these came
|
|
241
|
+
* from whatever started the server, past such a gap, and the server never uses it. One whose flags can't be read counts (one that closed meanwhile,
|
|
242
|
+
* as the listing's own does, doesn't).
|
|
243
|
+
*/
|
|
244
|
+
function inheritable(): number[] {
|
|
245
|
+
const fds: number[] = [];
|
|
246
|
+
for (const name of readdirSync('/proc/self/fd')) {
|
|
247
|
+
const fd = Number(name);
|
|
248
|
+
if (!Number.isInteger(fd) || fd <= 2) continue;
|
|
249
|
+
let info: string;
|
|
250
|
+
try {
|
|
251
|
+
info = readFileSync(`/proc/self/fdinfo/${fd}`, 'utf8');
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
const flags = /^flags:\s*([0-7]+)$/m.exec(info)?.[1];
|
|
257
|
+
if (flags === undefined || (parseInt(flags, 8) & O_CLOEXEC) === 0) fds.push(fd);
|
|
258
|
+
}
|
|
259
|
+
return fds;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Closes every descriptor a sandboxed command would get from this process (see inheritable()), just before one starts:
|
|
264
|
+
* null when none is left, else why nothing may start. Run synchronously right before the spawn, so nothing of the
|
|
265
|
+
* server's opens one in between.
|
|
266
|
+
*/
|
|
267
|
+
export function closeInherited({ quiet = false }: { quiet?: boolean } = {}): string | null {
|
|
268
|
+
try {
|
|
269
|
+
const fds = inheritable();
|
|
270
|
+
if (!fds.length) return null;
|
|
271
|
+
for (const fd of fds) {
|
|
272
|
+
try {
|
|
273
|
+
closeSync(fd);
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
if (!quiet) log.warn('closed file descriptors the server was started with, so no sandboxed command gets them', { fds });
|
|
277
|
+
const left = inheritable();
|
|
278
|
+
return left.length ? `Botons could not close ${left.length} file descriptor(s) it was started with, which a sandboxed command would get. Workspace access can't run until the server is started without them.` : null;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return `Botons could not list the file descriptors it holds (${(err as Error).message}), so it can't keep them from a sandboxed command. Workspace access can't run without that.`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Why Workspace tools can't run here, or null when bubblewrap just made a sandbox with the real view and the filter
|
|
286
|
+
* on, and a named pipe of the check's own, outside the sandbox, got nothing when the sandbox wrote to it through that
|
|
287
|
+
* view (then cached). Any of it missing, and Workspace tools don't run: nothing falls back to a weaker sandbox.
|
|
288
|
+
*/
|
|
289
|
+
export function sandboxProblem(bin = BWRAP, { quiet = false }: { quiet?: boolean } = {}): string | null {
|
|
290
|
+
const filter = seccompFilter();
|
|
291
|
+
const key = keyOf(bin, filter);
|
|
292
|
+
const was = checks.get(key);
|
|
293
|
+
if (was) {
|
|
294
|
+
// A pass stands while the same file is found the same way; a failure for a while, then it is tried again.
|
|
295
|
+
if (was.problem === null && filter && verifiedExe(bin, filter)) return null;
|
|
296
|
+
if (was.problem !== null && Date.now() - was.at < FAILED_MS) return was.problem;
|
|
297
|
+
}
|
|
298
|
+
let problem: string | null = null;
|
|
299
|
+
let exe: Exe | null = null;
|
|
300
|
+
if (process.platform !== 'linux') problem = `The sandbox Workspace access needs is bubblewrap on Linux; this server runs on ${process.platform}.`;
|
|
301
|
+
else if (!filter) problem = `The sandbox Workspace access needs has no socket filter for this machine's architecture (${process.arch}); it is built for x64 and arm64. Workspace access can't run without it.`;
|
|
302
|
+
else {
|
|
303
|
+
exe = resolveExe(bin);
|
|
304
|
+
problem = exe ? check(exe.path, filter, quiet) : 'bubblewrap (bwrap) is not installed, so Workspace access has no sandbox to run in. Install it (the bubblewrap package), or give the Boton Full computer access.';
|
|
305
|
+
// The file checked must be the file found now: one that changed while it was checked is checked again next time.
|
|
306
|
+
const after = exe && resolveExe(bin);
|
|
307
|
+
if (!problem && (!after || after.path !== exe!.path || after.id !== exe!.id)) problem = 'bubblewrap changed while Botons checked it. Workspace access can’t run until it checks again.';
|
|
308
|
+
}
|
|
309
|
+
if (!quiet && problem && was?.problem !== problem) log.warn('the Workspace sandbox for API-key Botons is unavailable', { reason: problem });
|
|
310
|
+
checks.delete(key);
|
|
311
|
+
if (checks.size >= MAX_CHECKS) checks.delete(checks.keys().next().value!);
|
|
312
|
+
checks.set(key, { at: Date.now(), problem, exe: problem ? null : exe });
|
|
313
|
+
return problem;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The bubblewrap whose check passed (sandboxProblem()), by its real path, for something else that starts in it (a
|
|
318
|
+
* Workspace turn's local connectors, connector-launch.ts); or why there is none.
|
|
319
|
+
*/
|
|
320
|
+
export function sandboxBinary(): { bin: string } | { problem: string } {
|
|
321
|
+
const problem = sandboxProblem();
|
|
322
|
+
if (problem) return { problem };
|
|
323
|
+
const filter = seccompFilter();
|
|
324
|
+
const bin = filter ? verifiedExe(BWRAP, filter) : null;
|
|
325
|
+
return bin ? { bin } : { problem: 'bubblewrap changed while Botons checked it.' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `bin` is the real path of the file to run (resolved by the caller), never a name looked up on PATH again. */
|
|
329
|
+
function check(bin: string, filter: Buffer, quiet = false): string | null {
|
|
330
|
+
const dir = mkdtempSync(join(tmpdir(), 'botons-sandbox-check-'));
|
|
331
|
+
const fds: number[] = [];
|
|
332
|
+
try {
|
|
333
|
+
const probe = join(dir, 'probe');
|
|
334
|
+
const ws = join(dir, 'ws');
|
|
335
|
+
mkdirSync(probe, { mode: 0o700 });
|
|
336
|
+
mkdirSync(ws, { mode: 0o700 });
|
|
337
|
+
const fifo = join(probe, 'probe.fifo');
|
|
338
|
+
const made = spawnSync('mkfifo', ['-m', '600', fifo], { timeout: CHECK_TIMEOUT_MS, env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } });
|
|
339
|
+
if (made.status !== 0) return 'Botons could not make the named pipe the sandbox check needs (mkfifo). Workspace access can’t run without the check.';
|
|
340
|
+
// The host side: a reader that never waits, which sees anything that reaches the pipe.
|
|
341
|
+
const reader = openSync(fifo, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
342
|
+
fds.push(reader);
|
|
343
|
+
// The filter goes in as a file: spawnSync takes a descriptor, not data, for a fourth stdio slot.
|
|
344
|
+
const file = join(dir, 'filter');
|
|
345
|
+
writeFileSync(file, filter, { mode: 0o600 });
|
|
346
|
+
const filterFd = openSync(file, 'r');
|
|
347
|
+
fds.push(filterFd);
|
|
348
|
+
const held = closeInherited({ quiet });
|
|
349
|
+
if (held) return held;
|
|
350
|
+
const plan: SandboxPlan = { cwd: ws, writable: [ws], hidden: [], readOnly: [], env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } };
|
|
351
|
+
const args = bwrapArgs(plan);
|
|
352
|
+
args.splice(BASE.length, 0, '--overlay-src', emptyLayer(), '--overlay-src', probe, '--ro-overlay', PROBE_AT);
|
|
353
|
+
const r = spawnSync(bin, [...args, '--', '/bin/sh', '-c', CHECK_SCRIPT], { timeout: CHECK_TIMEOUT_MS, stdio: ['ignore', 'ignore', 'pipe', filterFd], env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } });
|
|
354
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === 'ENOENT') return 'bubblewrap (bwrap) is not installed, so Workspace access has no sandbox to run in. Install it (the bubblewrap package), or give the Boton Full computer access.';
|
|
355
|
+
if (r.error || r.status !== 0) {
|
|
356
|
+
const why = CHECK_FAILED[r.status ?? -1] ?? (String(r.stderr ?? '').trim().split('\n').pop()?.slice(0, 200) || (r.error ? r.error.message : `exit ${r.status}`));
|
|
357
|
+
const { unshown } = viewOf(plan);
|
|
358
|
+
const left = unshown.length ? ` It leaves out ${unshown.join(', ')}, which another filesystem is mounted inside.` : '';
|
|
359
|
+
return `bubblewrap could not make the sandbox here, with its read-only view and its socket filter (${why}): user namespaces, overlays or seccomp may be turned off, as in many containers, or this bubblewrap is older than 0.11.${left} Workspace access can't run without it.`;
|
|
360
|
+
}
|
|
361
|
+
let got = 0;
|
|
362
|
+
try {
|
|
363
|
+
got = readSync(reader, Buffer.alloc(64), 0, 64, null);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if ((err as NodeJS.ErrnoException).code !== 'EAGAIN') throw err;
|
|
366
|
+
}
|
|
367
|
+
if (got > 0) return 'The sandbox’s read-only view let a write reach a named pipe outside it, so it can’t keep a Workspace Boton from programs on this machine. Workspace access can’t run here.';
|
|
368
|
+
const { unshown } = viewOf(plan);
|
|
369
|
+
if (!quiet && unshown.length) log.info('the Workspace sandbox leaves out folders another filesystem is mounted inside', { paths: unshown });
|
|
370
|
+
return null;
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return `Botons could not check the sandbox (${(err as Error).message}). Workspace access can't run without it.`;
|
|
373
|
+
} finally {
|
|
374
|
+
for (const fd of fds) closeSync(fd);
|
|
375
|
+
rmSync(dir, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Every namespace bubblewrap can make; the root is its own empty tmpfs (made read-only once the view is in place). */
|
|
380
|
+
const BASE = ['--die-with-parent', '--new-session', '--unshare-all', '--cap-drop', 'ALL'];
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The host trees a sandboxed command sees, each read-only: what programs and their libraries, settings and data need.
|
|
384
|
+
* Nothing else of the host is there: no home folder (but the path down to the workspace), no /var, /srv, /mnt, /media,
|
|
385
|
+
* /sys or the host's /tmp and /run. A link among them (a merged /usr) is the same link inside.
|
|
386
|
+
*/
|
|
387
|
+
export const SYSTEM_TREES = ['/usr', '/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/etc', '/opt', '/nix', '/nix/store', '/snap', '/gnu/store'];
|
|
388
|
+
|
|
389
|
+
let empty: string | null = null;
|
|
390
|
+
/** An empty folder of this process's: the second lower layer a read-only overlay needs. */
|
|
391
|
+
function emptyLayer(): string {
|
|
392
|
+
if (!empty || !existsSync(empty)) empty = mkdtempSync(join(tmpdir(), 'botons-sandbox-empty-'));
|
|
393
|
+
return empty;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The trees a plan shows: the system ones, then the folders its PATH lists and Node's own install (the toolchains a
|
|
398
|
+
* workspace's commands run), each one only where it isn't inside one already shown or a hidden path.
|
|
399
|
+
*
|
|
400
|
+
* A folder with another filesystem mounted inside it (/snap with each snap's image, /nix with /nix/store) is not
|
|
401
|
+
* shown whole: in bubblewrap's user namespace those mounts are locked, and the kernel refuses an overlay whose lower
|
|
402
|
+
* layer has one beneath it (EINVAL), which would leave the whole sandbox unmade. An overlay never shows what is
|
|
403
|
+
* mounted beneath its lower layer anyway. Such a folder is left out (`unshown`), never bound instead, and what lies
|
|
404
|
+
* inside it that the view lists on its own (/nix/store, a folder on PATH) is still tried as its own tree.
|
|
405
|
+
*/
|
|
406
|
+
export function viewOf(plan: SandboxPlan): { trees: { path: string; link: string | null }[]; unshown: string[]; masked: string[] } {
|
|
407
|
+
const hidden = plan.hidden.flatMap((p) => [resolve(p), real(p)]);
|
|
408
|
+
const mounts = mountPoints();
|
|
409
|
+
const trees: { path: string; link: string | null }[] = [];
|
|
410
|
+
const unshown: string[] = [];
|
|
411
|
+
const shown = (p: string) => trees.some((t) => !t.link && within(p, t.path));
|
|
412
|
+
const mountedInside = (p: string) => {
|
|
413
|
+
const r = real(p);
|
|
414
|
+
return mounts.some((m) => m !== r && within(m, r));
|
|
415
|
+
};
|
|
416
|
+
const show = (path: string) => {
|
|
417
|
+
if (mountedInside(path)) unshown.push(path);
|
|
418
|
+
else trees.push({ path, link: null });
|
|
419
|
+
};
|
|
420
|
+
for (const path of SYSTEM_TREES) {
|
|
421
|
+
try {
|
|
422
|
+
const st = lstatSync(path);
|
|
423
|
+
if (st.isSymbolicLink()) trees.push({ path, link: readlinkSync(path) });
|
|
424
|
+
else if (st.isDirectory() && !shown(path)) show(path);
|
|
425
|
+
} catch {}
|
|
426
|
+
}
|
|
427
|
+
const tools = [dirname(dirname(real(process.execPath))), ...(plan.env.PATH ?? '').split(':').filter((d) => isAbsolute(d)).map(real), ...(plan.trees ?? []).map(real)];
|
|
428
|
+
for (const path of tools) {
|
|
429
|
+
if (path === '/' || shown(path) || unshown.includes(path) || hidden.some((h) => within(path, h) || within(h, path)) || plan.writable.some((w) => within(path, real(w)))) continue;
|
|
430
|
+
try {
|
|
431
|
+
if (statSync(path).isDirectory()) show(path);
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
if (!plan.apart) return { trees, unshown, masked: [] };
|
|
435
|
+
return keepApart(trees, unshown, plan.apart);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** One line of a mountinfo file: the filesystem (major:minor), the folder of it mounted, and where. */
|
|
439
|
+
export interface Mount {
|
|
440
|
+
dev: string;
|
|
441
|
+
root: string;
|
|
442
|
+
at: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const unescape = (m: string) => m.replace(/\\([0-7]{3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)));
|
|
446
|
+
|
|
447
|
+
/** Each line of a mountinfo file, in its order (a later mount at the same place is the one on top). */
|
|
448
|
+
export function parseMountTable(text: string): Mount[] {
|
|
449
|
+
return text
|
|
450
|
+
.split('\n')
|
|
451
|
+
.map((line) => line.split(' '))
|
|
452
|
+
.filter((f) => f.length > 4 && f[3]!.startsWith('/') && f[4]!.startsWith('/'))
|
|
453
|
+
.map((f) => ({ dev: f[2]!, root: unescape(f[3]!), at: unescape(f[4]!) }));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let mountText = (): string => readFileSync('/proc/self/mountinfo', 'utf8');
|
|
457
|
+
|
|
458
|
+
/** Tests only (set in code, never from a setting): another mount table, or none (null: it can't be read). */
|
|
459
|
+
export function useMountTable(text: string | null | undefined): void {
|
|
460
|
+
mountText = text === undefined ? () => readFileSync('/proc/self/mountinfo', 'utf8') : () => {
|
|
461
|
+
if (text === null) throw new Error('no mount table');
|
|
462
|
+
return text;
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** This process's mounts, or null when they can't be read. */
|
|
467
|
+
export function mountTable(): Mount[] | null {
|
|
468
|
+
try {
|
|
469
|
+
return parseMountTable(mountText());
|
|
470
|
+
} catch {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Where `p` (a real path) really is: its filesystem, and its path in that filesystem from the top mount it lies in. Two
|
|
477
|
+
* paths with the same filesystem, one inside the other there, are the same files, whatever their paths say.
|
|
478
|
+
*/
|
|
479
|
+
export function placeOf(p: string, mounts: Mount[]): { dev: string; path: string } | null {
|
|
480
|
+
let best: Mount | null = null;
|
|
481
|
+
for (const m of mounts) if (within(p, m.at) && (!best || m.at.length >= best.at.length)) best = m;
|
|
482
|
+
if (!best) return null;
|
|
483
|
+
const rest = p.slice(best.at === '/' ? 0 : best.at.length);
|
|
484
|
+
return { dev: best.dev, path: rest ? join(best.root, rest) : best.root };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function keepApart(trees: { path: string; link: string | null }[], unshown: string[], apart: string[]) {
|
|
488
|
+
const mounts = mountTable();
|
|
489
|
+
if (!mounts) return { trees: [], unshown: [...unshown, ...trees.filter((t) => !t.link).map((t) => t.path)], masked: [] };
|
|
490
|
+
const roots = new Set<string>();
|
|
491
|
+
for (const a of apart) {
|
|
492
|
+
for (const p of new Set([resolve(a), real(a)])) {
|
|
493
|
+
roots.add(p);
|
|
494
|
+
// Another filesystem mounted inside one is part of it: its own files, wherever else they show.
|
|
495
|
+
for (const m of mounts) if (within(m.at, p)) roots.add(m.at);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const places = [...roots].map((r) => placeOf(r, mounts)).filter((x): x is { dev: string; path: string } => !!x);
|
|
499
|
+
const kept: { path: string; link: string | null }[] = [];
|
|
500
|
+
const masked: string[] = [];
|
|
501
|
+
for (const t of trees) {
|
|
502
|
+
if (t.link) {
|
|
503
|
+
kept.push(t);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const at = placeOf(real(t.path), mounts);
|
|
507
|
+
if (!at) {
|
|
508
|
+
unshown.push(t.path);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const same = places.filter((a) => a.dev === at.dev);
|
|
512
|
+
if (same.some((a) => within(at.path, a.path))) {
|
|
513
|
+
unshown.push(t.path);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
kept.push(t);
|
|
517
|
+
for (const a of same) if (within(a.path, at.path)) masked.push(join(t.path, a.path.slice(at.path === '/' ? 0 : at.path.length)));
|
|
518
|
+
}
|
|
519
|
+
return { trees: kept, unshown, masked };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The mount points of this process's mount namespace, as /proc/self/mountinfo lists them (none where it can't be read). */
|
|
523
|
+
export function mountPoints(): string[] {
|
|
524
|
+
try {
|
|
525
|
+
return parseMountinfo(readFileSync('/proc/self/mountinfo', 'utf8'));
|
|
526
|
+
} catch {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The mount point of each line of a mountinfo file: its fifth field, with the kernel's octal escapes (\040) undone. */
|
|
532
|
+
export function parseMountinfo(text: string): string[] {
|
|
533
|
+
return text
|
|
534
|
+
.split('\n')
|
|
535
|
+
.map((line) => line.split(' ')[4])
|
|
536
|
+
.filter((m): m is string => !!m && m.startsWith('/'))
|
|
537
|
+
.map((m) => m.replace(/\\([0-7]{3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8))));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const real = (p: string) => {
|
|
541
|
+
try {
|
|
542
|
+
return realpathSync(p);
|
|
543
|
+
} catch {
|
|
544
|
+
return resolve(p);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const within = (p: string, dir: string) => p === dir || p.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The bubblewrap arguments for `plan`, up to the command. Each host tree is a read-only overlay, never a bind: a named
|
|
552
|
+
* pipe or a socket file seen through an overlay is the overlay's own inode, so opening or connecting to it never
|
|
553
|
+
* reaches the program on the host that made it (the check in sandboxProblem() proves that on this machine first).
|
|
554
|
+
*/
|
|
555
|
+
export function bwrapArgs(plan: SandboxPlan): string[] {
|
|
556
|
+
const args = [...BASE, ...(plan.network ? ['--share-net'] : [])];
|
|
557
|
+
const layer = emptyLayer();
|
|
558
|
+
const { trees, masked } = viewOf(plan);
|
|
559
|
+
for (const t of trees) {
|
|
560
|
+
if (t.link) args.push('--symlink', t.link, t.path);
|
|
561
|
+
else args.push('--overlay-src', layer, '--overlay-src', t.path, '--ro-overlay', t.path);
|
|
562
|
+
}
|
|
563
|
+
args.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/var/tmp', '--tmpfs', '/run');
|
|
564
|
+
// A hidden path inside a tree it shows, and, when it is a link, what it points to; the outermost only.
|
|
565
|
+
const inView = (p: string) => trees.some((t) => !t.link && within(p, t.path));
|
|
566
|
+
if (plan.network) {
|
|
567
|
+
const conf = resolverFile(plan, inView);
|
|
568
|
+
if (conf) args.push('--ro-bind', conf, conf);
|
|
569
|
+
}
|
|
570
|
+
const hidden = new Map<string, boolean>();
|
|
571
|
+
for (const p of [...plan.hidden, ...masked]) {
|
|
572
|
+
for (const path of new Set([resolve(p), real(p)])) {
|
|
573
|
+
if (!inView(path)) continue;
|
|
574
|
+
try {
|
|
575
|
+
const st = lstatSync(path);
|
|
576
|
+
if (st.isSymbolicLink()) continue;
|
|
577
|
+
hidden.set(path, st.isDirectory());
|
|
578
|
+
} catch {}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const outer = [...hidden.keys()].filter((p) => ![...hidden.keys()].some((o) => o !== p && within(p, o)));
|
|
582
|
+
for (const p of outer.sort()) {
|
|
583
|
+
if (hidden.get(p)) args.push('--tmpfs', p, '--remount-ro', p);
|
|
584
|
+
else args.push('--ro-bind', '/dev/null', p);
|
|
585
|
+
}
|
|
586
|
+
for (const w of plan.writable) args.push('--bind', real(w), real(w));
|
|
587
|
+
for (const ro of plan.readOnly) {
|
|
588
|
+
if (ro.dir) mkdirSync(ro.path, { recursive: true });
|
|
589
|
+
if (!existsSync(ro.path)) continue;
|
|
590
|
+
const p = real(ro.path);
|
|
591
|
+
// Only on top of a folder it could otherwise write: anything else is read-only already.
|
|
592
|
+
if (plan.writable.some((w) => within(p, real(w)))) args.push('--ro-bind', p, p);
|
|
593
|
+
}
|
|
594
|
+
args.push('--remount-ro', '/', '--seccomp', String(FILTER_FD), '--chdir', real(plan.cwd), '--clearenv');
|
|
595
|
+
for (const [k, v] of Object.entries(plan.env)) args.push('--setenv', k, v);
|
|
596
|
+
return args;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The file /etc/resolv.conf points to when that is outside the view (systemd-resolved's, in /run): a regular file, not
|
|
601
|
+
* in a folder the plan keeps apart. Null when there is none to add.
|
|
602
|
+
*/
|
|
603
|
+
function resolverFile(plan: SandboxPlan, inView: (p: string) => boolean): string | null {
|
|
604
|
+
try {
|
|
605
|
+
if (!lstatSync('/etc/resolv.conf').isSymbolicLink()) return null;
|
|
606
|
+
const conf = realpathSync('/etc/resolv.conf');
|
|
607
|
+
if (inView(conf) || !statSync(conf).isFile()) return null;
|
|
608
|
+
const mounts = plan.apart ? mountTable() : [];
|
|
609
|
+
if (!mounts) return null;
|
|
610
|
+
const at = placeOf(conf, mounts);
|
|
611
|
+
const near = (plan.apart ?? []).map((a) => placeOf(real(a), mounts));
|
|
612
|
+
if (plan.apart && (!at || near.some((a) => !a || (a.dev === at.dev && (within(at.path, a.path) || within(a.path, at.path)))))) return null;
|
|
613
|
+
return conf;
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface ProcessResult {
|
|
620
|
+
code: number | null;
|
|
621
|
+
signal: NodeJS.Signals | null;
|
|
622
|
+
stdout: Buffer;
|
|
623
|
+
stderr: string;
|
|
624
|
+
/** More output came than `maxBytes`: the middle of it was left out. */
|
|
625
|
+
truncated: boolean;
|
|
626
|
+
timedOut: boolean;
|
|
627
|
+
aborted: boolean;
|
|
628
|
+
/** Its time ran out or it was stopped while running: what it did before that stands, and may be partial. */
|
|
629
|
+
cutShort: boolean;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export interface RunOptions {
|
|
633
|
+
argv: string[];
|
|
634
|
+
/** null: run as the server's user, outside any sandbox (Full computer access). */
|
|
635
|
+
sandbox: SandboxPlan | null;
|
|
636
|
+
/** Without a sandbox: where it runs and its whole environment. */
|
|
637
|
+
cwd: string;
|
|
638
|
+
env: Record<string, string>;
|
|
639
|
+
stdin?: Buffer | string;
|
|
640
|
+
timeoutMs: number;
|
|
641
|
+
signal: AbortSignal;
|
|
642
|
+
/** Each of stdout and stderr keeps at most this many bytes: its start and its end. */
|
|
643
|
+
maxBytes: number;
|
|
644
|
+
/** stdout is kept whole up to maxBytes, never its end (a file being read). */
|
|
645
|
+
headOnly?: boolean;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Runs one process (in its own process group, all of it killed at the end of its time or on a stop). */
|
|
649
|
+
export function runProcess(opts: RunOptions): Promise<ProcessResult> {
|
|
650
|
+
return new Promise((resolvePromise, reject) => {
|
|
651
|
+
if (opts.signal.aborted) {
|
|
652
|
+
resolvePromise({ code: null, signal: null, stdout: Buffer.alloc(0), stderr: '', truncated: false, timedOut: false, aborted: true, cutShort: false });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// No sandboxed process runs without its filter.
|
|
656
|
+
const filter = opts.sandbox ? seccompFilter() : null;
|
|
657
|
+
if (opts.sandbox && !filter) {
|
|
658
|
+
reject(new Error(`the sandbox has no socket filter for ${process.arch}`));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// The bubblewrap its check passed, still the same file, by its real path: anything else, and nothing runs.
|
|
662
|
+
// Checked again here when it changed or its check is old (synchronously: nothing runs in between).
|
|
663
|
+
let bin = opts.sandbox && filter ? verifiedExe(BWRAP, filter) : null;
|
|
664
|
+
if (opts.sandbox && !bin) {
|
|
665
|
+
const problem = sandboxProblem();
|
|
666
|
+
bin = problem ? null : verifiedExe(BWRAP, filter!);
|
|
667
|
+
if (!bin) {
|
|
668
|
+
reject(new Error(problem ?? 'the sandbox changed while Botons checked it'));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const [cmd, args] = opts.sandbox ? [bin!, [...bwrapArgs(opts.sandbox), '--', ...opts.argv]] : [opts.argv[0], opts.argv.slice(1)];
|
|
673
|
+
// Last before the spawn, synchronously: no descriptor the server was started with goes in.
|
|
674
|
+
const held = opts.sandbox ? closeInherited() : null;
|
|
675
|
+
if (held) {
|
|
676
|
+
reject(new Error(held));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const child = spawn(cmd, args, {
|
|
680
|
+
cwd: opts.sandbox ? '/' : opts.cwd,
|
|
681
|
+
env: opts.sandbox ? { PATH: process.env.PATH ?? '/usr/bin:/bin' } : opts.env,
|
|
682
|
+
// The filter's pipe: bubblewrap reads it whole and closes it before the command starts.
|
|
683
|
+
stdio: opts.sandbox ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
684
|
+
detached: true,
|
|
685
|
+
});
|
|
686
|
+
if (filter) {
|
|
687
|
+
const pipe = child.stdio[FILTER_FD] as NodeJS.WritableStream & { on: (e: string, f: () => void) => void };
|
|
688
|
+
pipe.on('error', () => {});
|
|
689
|
+
pipe.end(filter);
|
|
690
|
+
}
|
|
691
|
+
const out = new Bounded(opts.maxBytes, !!opts.headOnly);
|
|
692
|
+
const err = new Bounded(Math.min(opts.maxBytes, 16 * 1024), false);
|
|
693
|
+
let timedOut = false;
|
|
694
|
+
let aborted = false;
|
|
695
|
+
const kill = () => {
|
|
696
|
+
try {
|
|
697
|
+
if (child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
698
|
+
} catch {}
|
|
699
|
+
};
|
|
700
|
+
const onAbort = () => {
|
|
701
|
+
aborted = true;
|
|
702
|
+
kill();
|
|
703
|
+
};
|
|
704
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
705
|
+
const timer = setTimeout(() => {
|
|
706
|
+
timedOut = true;
|
|
707
|
+
kill();
|
|
708
|
+
}, opts.timeoutMs);
|
|
709
|
+
child.stdout.on('data', (c: Buffer) => out.add(c));
|
|
710
|
+
child.stderr.on('data', (c: Buffer) => err.add(c));
|
|
711
|
+
child.stdin.on('error', () => {});
|
|
712
|
+
child.stdin.end(opts.stdin ?? '');
|
|
713
|
+
child.on('error', (e) => {
|
|
714
|
+
clearTimeout(timer);
|
|
715
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
716
|
+
reject(e);
|
|
717
|
+
});
|
|
718
|
+
child.on('close', (code, signal) => {
|
|
719
|
+
clearTimeout(timer);
|
|
720
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
721
|
+
// Anything it left running in its group goes too.
|
|
722
|
+
kill();
|
|
723
|
+
const stdout = out.value();
|
|
724
|
+
resolvePromise({ code, signal, stdout, stderr: err.value().toString('utf8'), truncated: out.cut || err.cut, timedOut, aborted, cutShort: timedOut || aborted });
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** At most `max` bytes: the first part and (unless headOnly) the last, with how much was left out between. */
|
|
730
|
+
class Bounded {
|
|
731
|
+
#max: number;
|
|
732
|
+
#headOnly: boolean;
|
|
733
|
+
#head: Buffer[] = [];
|
|
734
|
+
#headSize = 0;
|
|
735
|
+
#tail = Buffer.alloc(0);
|
|
736
|
+
#dropped = 0;
|
|
737
|
+
cut = false;
|
|
738
|
+
constructor(max: number, headOnly: boolean) {
|
|
739
|
+
this.#max = max;
|
|
740
|
+
this.#headOnly = headOnly;
|
|
741
|
+
}
|
|
742
|
+
add(c: Buffer) {
|
|
743
|
+
const headMax = this.#headOnly ? this.#max : Math.floor(this.#max * 0.6);
|
|
744
|
+
if (this.#headSize < headMax) {
|
|
745
|
+
const take = c.subarray(0, headMax - this.#headSize);
|
|
746
|
+
this.#head.push(take);
|
|
747
|
+
this.#headSize += take.length;
|
|
748
|
+
c = c.subarray(take.length);
|
|
749
|
+
}
|
|
750
|
+
if (!c.length) return;
|
|
751
|
+
this.cut = true;
|
|
752
|
+
if (this.#headOnly) {
|
|
753
|
+
this.#dropped += c.length;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const tailMax = this.#max - headMax;
|
|
757
|
+
const joined = Buffer.concat([this.#tail, c]);
|
|
758
|
+
this.#dropped += Math.max(0, joined.length - tailMax);
|
|
759
|
+
this.#tail = joined.subarray(Math.max(0, joined.length - tailMax));
|
|
760
|
+
}
|
|
761
|
+
value(): Buffer {
|
|
762
|
+
const head = Buffer.concat(this.#head);
|
|
763
|
+
if (!this.#dropped) return Buffer.concat([head, this.#tail]);
|
|
764
|
+
if (this.#headOnly) return head;
|
|
765
|
+
return Buffer.concat([head, Buffer.from(`\n[… ${this.#dropped} bytes left out …]\n`), this.#tail]);
|
|
766
|
+
}
|
|
767
|
+
}
|