diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c201b6b12..3495363a0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -13,7 +13,7 @@ permissions: # certificate and of its public key. jobs: integration-tests: - name: Tests (${{ matrix.os }} / ${{ matrix.arch }}) + name: Tests (${{ matrix.os }} / ${{ matrix.label || matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -32,6 +32,13 @@ jobs: - arch: arm64 runner: macos-15 os: macos + # The newest macOS as well: the sandbox profile's own regex engine + # and what Seatbelt refuses have both moved between releases, and + # the two legs above stay on the images they name. + - arch: arm64 + label: arm64-macos26 + runner: macos-26 + os: macos # Windows legs build srt-win, run the Rust smoke scripts, # then the TS integration suite (test/sandbox/winsrt.test.ts). - arch: x86-64 @@ -398,7 +405,36 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install -y bubblewrap libseccomp-dev gcc socat ripgrep apparmor-profiles zsh + sudo apt-get install -y bubblewrap libseccomp-dev gcc socat apparmor-profiles zsh + + # The library reads both the output and the exit status of ripgrep, and + # those differ between releases: for an unreadable directory exactly at + # --max-depth, 14.1.1 prints "Permission denied" and exits 2, where + # 15.2.0 passes over it and exits 0. 14.1.1 is what most installations + # have, so every later step finds it first on PATH; the second full run + # below puts 15.2.0 ahead of it. Both are upstream's archives, by the + # checksums upstream publishes. macOS runs whatever Homebrew serves. + - name: Install ripgrep 14.1.1 and 15.2.0 (Linux) + if: matrix.os == 'linux' + run: | + install_ripgrep() { + case "${{ matrix.arch }}" in + x86-64) asset=ripgrep-$1-x86_64-unknown-linux-musl.tar.gz sha256=$2 ;; + arm64) asset=ripgrep-$1-aarch64-unknown-linux-gnu.tar.gz sha256=$3 ;; + esac + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/$1/$asset" + echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict + mkdir "$RUNNER_TEMP/ripgrep-$1" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-$1" --strip-components=1 + "$RUNNER_TEMP/ripgrep-$1/rg" --version + } + install_ripgrep 14.1.1 \ + 4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e \ + c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f + install_ripgrep 15.2.0 \ + 33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c \ + a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d + echo "$RUNNER_TEMP/ripgrep-14.1.1" >> "$GITHUB_PATH" - name: Enable unprivileged user namespaces (Linux) if: matrix.os == 'linux' @@ -424,18 +460,20 @@ jobs: - name: Install Node dependencies if: matrix.os != 'windows' - run: npm install + run: npm ci + # Builds dist/ as `npm run build` does, which compiles src only. This is + # the one step that checks the types of test/, which bun strips at runtime. + - name: Build and typecheck + if: matrix.os != 'windows' + run: npm run typecheck + + # After the type check, which builds dist/: test/smoke imports it, and + # without it the rules that read types see `any` there and say nothing. - name: Lint (ESLint) if: matrix.os == 'linux' && matrix.arch == 'x86-64' run: npx eslint . - # `npm run build` compiles src only. This is the one step that checks - # the types of test/, which bun strips at runtime. - - name: Typecheck - if: matrix.os != 'windows' - run: npm run typecheck - - name: Build seccomp binaries (Linux) if: matrix.os == 'linux' run: npm run build:seccomp @@ -444,10 +482,6 @@ jobs: if: matrix.os != 'windows' run: npm run build:java-agent - - name: Build project - if: matrix.os != 'windows' - run: npm run build - - name: Run tests if: matrix.os != 'windows' run: npm test @@ -470,9 +504,9 @@ jobs: ninja -C _build install "$RUNNER_TEMP/bwrap-0.12/bin/bwrap" --version - - name: Run tests against bubblewrap 0.12.0 (Linux) + - name: Run tests against bubblewrap 0.12.0 and ripgrep 15.2.0 (Linux) if: matrix.os == 'linux' - run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$PATH" npm test + run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$RUNNER_TEMP/ripgrep-15.2.0:$PATH" npm test # RHEL 8 and 9 ship bubblewrap 0.4.0 and 0.4.1, the oldest versions in # current distribution use, and both refuse to start on a plan that @@ -515,11 +549,23 @@ jobs: if: matrix.os != 'windows' run: node test/utils/which-node-test.mjs + # Everything above ran with Node 22 on PATH. What runs the library under + # Node repeats on 24, in a step so that the matrix stays as it is. + - name: Setup Node.js 24 + if: matrix.os != 'windows' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Run the tests that use Node (Node 24) + if: matrix.os != 'windows' + run: npm run test:node + - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-${{ matrix.os }}-${{ matrix.arch }} + name: test-results-${{ matrix.os }}-${{ matrix.label || matrix.arch }} path: | test-results/ *.log @@ -679,6 +725,11 @@ jobs: with: node-version: '22' + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Enable unprivileged user namespaces on host run: | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true @@ -697,22 +748,22 @@ jobs: --security-opt apparmor=unconfined \ -v "${{ github.workspace }}:/work" \ -v "$(dirname "$(dirname "$(command -v node)")")":/opt/node:ro \ + -v "$(command -v bun)":/usr/local/bin/bun:ro \ -w /work \ -e SRT_E2E_DOCKER=1 \ ubuntu:24.04 \ bash -euo pipefail -c ' apt-get update -qq - # node and npm are those of the runner, bound in: no second - # piped installer, and not those of the image, which has Node - # 18, below what engines in package.json allows. bun is pinned - # to the version the rest of the matrix runs (oven-sh/setup-bun - # above). The tree carries a dozen bun-version-specific + # node, npm and bun are those of the runner, bound in: no piped + # installer, not the Node 18 of the image, which is below what + # engines in package.json allows, and the bun the rest of the + # matrix runs. The tree carries a dozen bun-version-specific # workarounds, so "whatever bun shipped this morning" is not # the runtime under test. - apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc libseccomp-dev libcap2-bin - curl -fsSL https://bun.sh/install | bash -s bun-v1.4.2 - export PATH="/opt/node/bin:$HOME/.bun/bin:$PATH" + apt-get install -y -qq bubblewrap socat ripgrep python3 ca-certificates gcc libseccomp-dev libcap2-bin + export PATH="/opt/node/bin:$PATH" node --version + bun --version npm ci npm run build:seccomp npm run build @@ -721,3 +772,35 @@ jobs: echo "== as uid 0 without CAP_SYS_ADMIN" capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" ' + + # The same two runs on musl, and the package as an embedder ships it. + # bubblewrap, socat, ripgrep and node are Alpine's own builds, and bun is + # its musl build, from the release archive by checksum. The rest is what + # the step above left in the workspace: node_modules and dist, which are + # JavaScript, and the seccomp helper, static against glibc, because that + # is the file the npm package brings to a musl host. + - name: Run srt end-to-end as uid 0 in an Alpine container + run: | + docker run --rm \ + --cap-add SYS_ADMIN \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + -v "${{ github.workspace }}:/work" \ + -w /work \ + -e SRT_E2E_DOCKER=1 \ + alpine:3.22 \ + sh -euc ' + apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip libcap-utils libstdc++ libgcc nodejs + case "$(uname -m)" in + x86_64) bun=bun-linux-x64-musl sha256=4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 ;; + aarch64) bun=bun-linux-aarch64-musl sha256=71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df ;; + esac + curl -fsSL --retry 3 -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/$bun.zip" + echo "$sha256 /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp + install -m 0755 "/tmp/$bun/bun" /usr/local/bin/bun + echo "== as uid 0 with CAP_SYS_ADMIN" + bun test test/docker-weak-sandbox.test.ts test/smoke/bundled.test.ts + echo "== as uid 0 without CAP_SYS_ADMIN" + capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" + ' diff --git a/README.md b/README.md index 7a96720d2..93161b5f4 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,7 @@ Uses two different patterns: - `filesystem.denyRead` - Array of paths to deny read access. Empty array = full read access. - `filesystem.allowRead` - Array of paths to re-allow read access within denied regions (takes precedence over denyRead). **Note:** this is the opposite of write, where `denyWrite` takes precedence over `allowWrite`. +- `filesystem.denyReadGlobBudget` - Optional, Linux only: `{ "maxEntries": 20000000, "timeoutMs": 60000 }` (the defaults). What expanding the globs of one read configuration may spend before the wrap is refused; see "Path Syntax (Linux)". **Write restrictions** (allow-only pattern) - all writes denied by default: @@ -545,7 +546,10 @@ Paths support git-style glob patterns on macOS, similar to `.gitignore` syntax: - `*` - Matches any characters except `/` (e.g., `*.ts` matches `foo.ts` but not `foo/bar.ts`) - `**` - Matches any characters including `/` (e.g., `src/**/*.ts` matches all `.ts` files in `src/`) - `?` - Matches any single character except `/` (e.g., `file?.txt` matches `file1.txt`) -- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`) +- `[abc]` - Matches any character in the set (e.g., `file[0-9].txt` matches `file3.txt`). Whatever stands between the brackets is a member, a `*` or a `?` too (`[*]` matches a `*` in a name), but for a `!` or `^` that negates (next entry). Brackets that spell no set, because a range in them runs backwards (`[user-id]`, `backup[2024-01-15]`) or there is nothing in them (`list[].ts`), are the text they are, on every platform; the rest of the entry is still a pattern (`app/**/[user-id]/*.env`) +- `[!abc]` and `[^abc]` - Matches any character outside the set, and never `/` (e.g., `file[!a-c].txt` matches `filed.txt` but not `fileb.txt`). Only a `!` or `^` straight after the `[` negates, and it is no member itself. With nothing after it (`[!]`, `[^]`) the brackets spell no set + +Two things a macOS sandbox profile's own regex engine does with these. It matches bytes, so `?` and a set, negated or not, match no character outside ASCII. And in a deny rule it reads a negated set that lists a `-` and one of `,` and `.`, not both (`[!-.]`, `[!+--]`), as any character but `/`, so that deny covers the characters it lists as well: wider than written, never narrower. Examples: @@ -564,6 +568,7 @@ bubblewrap binds concrete paths, so glob support is narrower than on macOS: - A directory matched by a `denyRead` pattern ending in `/**` that holds at least one entry when the command is wrapped becomes one tmpfs mount, like a directory listed in `denyRead` literally: inside the sandbox it is an EMPTY WRITABLE directory, so a command that used to write through a read-denied `build/` still writes, into the tmpfs, and loses that output when the command exits. A file added to the directory on the host afterwards is hidden too. A matched directory that is empty when the command is wrapped gets no mount (a matched symlink to a directory always gets one, on the directory it leads to). An `allowRead` beneath a mounted directory is bound back over the tmpfs, but each entry beneath it that the pattern matches keeps its own mask: under a `/**` pattern that is every entry there, so only what is created beneath the `allowRead` later is readable. - A directory the expansion cannot list is denied as a whole, and nothing is bound back beneath the mount that hides it, `allowRead` and `allowWrite` paths included: what the pattern matches under them cannot be found. So is an entry that may be a directory for all the expansion can learn: one the file system lists without a type and that cannot be looked at either. A `denyRead` entry that cannot be inspected (its parent directory is readable but not searchable, say), or that leads to `/`, hides the nearest directory above it instead, in the same way. - Symlinked directories are descended. A directory is listed once for each way the pattern can carry on beneath it, however many links lead to it, so the cost of the expansion follows the size of the tree and the length of the pattern, not the number or length of the names its links offer; what is found through a link is reported, and denied, where it really is. A link that leads back up the tree (to the directory holding it or above, or to the pattern's starting directory or above) is not descended. A `**` written against other text (`**.pem`, `a**/x`) and a bracket expression that can match `/` span directories as they do on macOS, through symlinked directories too. Every `denyRead` mount goes where the path really is (bubblewrap 0.12 and later refuse to mount on a symlink), so an entry reached through a symlink is denied under every name that leads to it; a link back up the tree is not descended, and denies what it reaches only when it is itself a match, which denies all of it, as a literal deny of the link would. A link that resolves to nothing is skipped. A link whose target cannot be looked at (a directory above the target is not searchable, say) is not followed: when the pattern would continue through it rather than match it, nothing is denied for it and what lies behind it is not masked. A link of that kind that is itself a match is still denied, under its own spelling. `allowRead` globs are not expanded through symlinks: they match the link itself. +- A read configuration's patterns (`allowRead`, `denyRead`, globs among `credentials.files`) are expanded on one shared budget: 20,000,000 directory entries read from disk and 60 seconds of wall-clock time, which a cold cache or busy host spends sooner. `filesystem.denyReadGlobBudget` (`maxEntries`, `timeoutMs`) changes it, in the configuration or in one wrap's `customConfig`, which replaces only the limit it names; anything but positive integers under those two keys fails the wrap. A directory several `denyRead` patterns walk is read and counted once, and kept until the last is expanded (100 to 200 bytes an entry). Past the budget, `wrapWithSandbox()`, `wrapWithSandboxArgv()` and `getFsReadConfig()` fail with a `LinuxSandboxProfileError`, `.code` `deny_glob_too_large`: a deny list cut short would leave readable what it should hide. `initialize()` and `updateConfig()` expand nothing and nothing is kept between wraps, so each wrap is refused only after spending the budget again. The message names the remedies; its facts are also on `.cause`, a `GlobWalkBudgetError` (type exported): `pattern` (the one being expanded, not always the large one), `directory`, `exhausted` (`'entries'` or `'time'`), `entries`, `elapsedMs`, `maxEntries`, `timeoutMs`. Time is checked at every directory and entry, so only one blocking call (a dead network mount) or one slow match can overrun it. - An `allowRead` or `allowWrite` path is bound back over a denied directory only where it really is, so no directory shows under a second name inside the sandbox. - `denyRead: ["/"]` denies each directory in `/` (`/proc`, `/dev` and `/sys` aside); a symlink there (`/bin`, `/lib` on a usr-merged system) gets no mount of its own, because what it leads to is denied together with the directory that holds it. @@ -593,9 +598,9 @@ Examples: - `javaAgentJarPath` - macOS/Linux: absolute path to `srt-proxy-agent.jar`, the JVM agent injected via `JAVA_TOOL_OPTIONS` (see "JVM tools" under Network Isolation). Only needed by consumers that bundle sandbox-runtime and ship the jar separately; a normal npm install finds it under `vendor/java-proxy-agent/`. - `enableWeakerNetworkIsolation` - Allow access to `com.apple.trustd.agent` in the macOS sandbox (boolean, default: false). This is needed for Go programs (`gh`, `gcloud`, `terraform`, `kubectl`, etc.) to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Security warning:** enabling this opens a potential data exfiltration vector through the trustd service. - `allowPty` - macOS only: allow pseudo-terminal operations (boolean, default: false). The seatbelt profile then carries `(allow pseudo-tty)` plus read, write and `ioctl` on `/dev/ptmx` and `/dev/ttys*`, which a command that allocates a pty of its own needs. Not read on Linux or Windows. -- `bwrapPath` - Linux only: absolute path to the `bwrap` (bubblewrap) binary, used instead of resolving `bwrap` on `PATH`. A path that is not executable is a dependency error, and no `PATH` lookup is tried after it. +- `bwrapPath` - Linux only: absolute path to the `bwrap` (bubblewrap) binary, used as given instead of resolving `bwrap` on `PATH` (see "Where these programs are taken from" under Platform-Specific Dependencies, which holds for `socat` and for a bare `ripgrep.command` too). A path that is not executable is a dependency error, and no `PATH` lookup is tried after it. - `socatPath` - Linux only: absolute path to the `socat` binary, used instead of resolving `socat` on `PATH`, with the same rule for a path that is not executable. -- `seccomp` - Linux only: `applyPath` points at the `apply-seccomp` binary to use instead of the packaged one, which is still used when nothing exists at that path. `argv0` invokes it as a multicall binary that dispatches on the `ARGV0` environment variable; `applyPath` is then used verbatim (no existence check) and must resolve inside the bubblewrap namespace. +- `seccomp` - Linux only: `applyPath` points at the `apply-seccomp` binary to use instead of the packaged one, which is still used when nothing exists at that path. `argv0` invokes it as a multicall binary that dispatches on the `ARGV0` environment variable; `applyPath` is then used verbatim (no existence check) and must resolve inside the bubblewrap namespace. `holdsLinks` says whether the helper takes `--hold-link` (see "Denied paths and symbolic links"), and is taken at its word: nothing is run to find out. Not set, a helper the library found for itself is read for the option's name, and one named by `applyPath` is handed no link. Set it from a probe of your own helper (`apply-seccomp --holds-links` exits 0), not from a constant: a helper without the option takes `--hold-link` for the command, so under a wrong `true` no sandboxed command starts where there is a link to hold. - `ripgrep` - Linux only: how to invoke ripgrep for the deny-path scan (default: `{ "command": "rg" }`). `args` are passed before srt's own arguments, and `argv0` overrides `argv[0]` for a multicall binary. - `git.safeDirectories` - Directories git should treat as `safe.directory` inside the sandbox, where the working tree is owned by another user — the repository top level when the command runs from a subdirectory, say. Injected through `GIT_CONFIG_*` environment variables, and it grants no write access of its own. - `credentials` - Environment variables (`credentials.envVars`) and files (`credentials.files`) the sandboxed command must not read the real values of. `mode: "deny"` withholds the value; `mode: "mask"` puts a sentinel in its place inside the sandbox and has the proxy substitute the real value back on egress to the hosts that entry's `injectHosts` lists (default: `network.allowedDomains`). Masking requires `network.tlsTerminate`, so the real value only leaves over a verified TLS connection, unless `credentials.allowPlaintextInject` opts out. Files are masked on Linux only; macOS denies a masked file instead. @@ -698,6 +703,16 @@ Watchman accesses files outside the sandbox boundaries, which will trigger permi **Supported bubblewrap versions:** 0.4.0 and later, except that one thing needs 0.5.0 or newer, a change to how bubblewrap prepares the mount point for a file bind: a `denyRead` entry or credential mask naming a path that is not a regular file — a fifo, a socket, a device node — cannot be applied on an older bubblewrap, which creates a file at the destination instead of binding over what is there (that fails on a read-only mount and blocks on a fifo). `checkDependencies()` runs `bwrap --version` (once for each path of `bwrap`: the answer, or that there was none, is kept for the life of the process) and returns a warning naming the version it found when bubblewrap is older than 0.5.0; it is a warning, not a refusal. CI runs the whole suite against the bubblewrap Ubuntu ships and against 0.12.0, and the Linux mount-plan suites against 0.4.1 as well. +**Where these programs are taken from.** `bubblewrap`, `socat` and `ripgrep` are run by the library itself, on the host, outside the sandbox; the shell of a wrapped command starts the relays and the seccomp helper before the filter is in force. All four are looked up on `PATH` inside the process (no `which` is run), and a copy the sandboxed command may write is passed over: one inside an allowed write path (`filesystem.allowWrite` and the default write paths), one reached through a symbolic link kept in such a path, and one found through a relative `PATH` entry. Such a file is whatever an earlier sandboxed command left there. This matters when `PATH` leads with a directory of the project, as under `npx` and `npm run` (`/node_modules/.bin`) with the project in `allowWrite`. + +- When no copy of the first three is left, `initialize()` and `checkDependencies()` report an error that names the copies passed over. A wrap left without `bubblewrap`, `socat` or its shell throws `LinuxSandboxProfileError` with code `host_helper_unavailable`, whose `.cause` holds the same: `helper`, and `skipped`, a list of `SkippedHostHelper` (`{ path, reason, writePath? }`). Without a `ripgrep` the wrap goes on as it does where none is installed: the scan is skipped, which is logged under `SRT_DEBUG`. +- The usual case is a writable home directory (it is the working directory, or is in `allowWrite`) with the programs installed under it (`~/.local/bin`, `~/.cargo/bin`, a `~/.nix-profile` link, version-manager shims). Install the program outside the write paths, or name it by its path with `bwrapPath`, `socatPath`, `ripgrep.command` or the wrap's `binShell`. A program named that way is run as given, and `checkDependencies()` warns when the sandboxed command may write one the configuration names. +- Before there is a configuration (`checkDependencies()` ahead of `initialize()`), the working directory and the default write paths count as writable. +- Where `apply-seccomp`, or the JVM agent's jar, is in none of the package's own locations and no option names it, the library asks `npm root -g` for a global install. `npm` and the `node` it starts are looked up on a `PATH` with the same places left out, and a global install of the helper that the command may write is passed over. An npm install of this package carries both files and asks nothing. +- On macOS `env`, `sandbox-exec` and `log` are run from `/usr/bin`, and the shell, which starts under `sandbox-exec`, is found on the whole `PATH`. + +Three limits. With writes allowed everywhere (`allowWrite: ['/']` with `denyWrite` entries, or `filesystem.disabled`) nothing is passed over but a relative `PATH` entry: no place is then better than another, and file permissions are not judged. The write paths are those in force when the program is looked up, the session's together with the call's own: a file left under a wider set, granted earlier by a per-call configuration or before `updateConfig()` narrowed it, is not recognised. And `socat` is named to the relays by its path on the host, so a `denyRead` entry that hides that copy stops them. + **Ubuntu 24.04+ note:** These releases enable `kernel.apparmor_restrict_unprivileged_userns` by default, which allows `unshare(CLONE_NEWUSER)` but strips capabilities from the resulting namespace. Both bubblewrap and the seccomp isolation layer need capability-bearing user namespaces. Disable the restriction with: ```bash @@ -1023,6 +1038,7 @@ Known limitations: - `network.tlsTerminate`, so credential masking of request headers is unavailable. - `filesystem.disabled` at the session level: MXC is allow-list in both directions, and the nearest request would restrict rather than lift restrictions. (Per command it keeps the session's policy, as on srt-win.) - `network.allowLocalBinding: true`, at the session level or per command: nothing outside the container can connect in. +- **The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) are not blocked.** A write deny reaches MXC as a full deny (a path in `denyWrite` cannot be read either), and git does not run without reading `.git\config`. List in `denyWrite` the ones a command need not read. - **Loopback with a proxy is closed by measurement, not by promise.** MXC 1.0 refuses a host proxy unless `ingress.default` is `allow`, and one without an app identity also needs host loopback allowed. On Windows 11 25H2 (build 26200.9711) that request still admitted no inbound connection, from loopback or the LAN, and blocked outbound connections to loopback ports other than the proxy's. MXC documents no guarantee of either. - **`%TEMP%`, `%TMP%` and `%LOCALAPPDATA%` are per-command scratch.** MXC points them at a folder of its own for each command and deletes it when the command ends, so a file written there is not seen by the next command or on the host. Caches kept under `LOCALAPPDATA` (npm, pip and similar) do not persist between commands. The host's own temp folder is still writable by its full path. - **The child's environment is sealed**: it gets a fixed set of Windows variables, the proxy settings and srt's own, never the user's profile environment. @@ -1172,6 +1188,18 @@ Filesystem restrictions are enforced at the OS level: **Writes through links (Linux and macOS):** a write is judged by where it lands, after symbolic links are followed. A link inside an allowed write path that leads out of it gives a command nothing: the write is refused. A link that leads into an allowed write path works like any other name for that place. A hard link is a second name for the file itself: on Linux a file outside the allowed write paths that already has a hard link inside one can be written through it, and a sandboxed command cannot create such a link. On macOS nothing is promised for such a file. +**Denied paths and symbolic links (Linux and macOS):** a `denyWrite` entry or a [mandatory deny path](#mandatory-deny-paths-auto-protected-files) with a symbolic link on the way to it, its last name included, denies the path the links lead to, whether or not anything is there yet, and keeps each link where it is: removing, renaming and replacing it are refused, and so is renaming a directory above it. A directory that a link's target goes into and out of again by `..` is kept in place too, and a name that is not there and that the target goes up from cannot be made. The link reads and leads as before, and the rest of its directory stays as writable as it was. The links are those found when the command is wrapped. + +- Nothing is denied among the devices: a name blanked with a link to `/dev/null` is kept, and `/dev/null` stays as it was. +- A tool that replaces a file instead of writing into it (`sed -i`, `mv -f`, `ln -sfn`, `rsync`, `tar x`, `git checkout` where the tracked link differs, `git clean -fdx`) is refused at such a name, as at any denied one, on Linux with `EBUSY` ("Device or resource busy"). +- A directory on the way that cannot be looked in (no search permission, or a look that fails twice for any reason but that nothing is or can be at the name) is denied whole in place of the path, its mode included. Never `/`: there the path is taken as written. +- On Linux the same links are kept on the way to a `denyRead` entry, to a link that a `denyRead` pattern matches, and to a `credentials.files` path: the next command's mounts are placed by the name again. +- On Linux bubblewrap cannot mount on a link, so the seccomp helper does (`apply-seccomp --hold-link `): each link in a directory the command can write to is mounted on itself, read-only, in the mount namespaces made for the command. Where that cannot be done (the name is no link any more, the link says something else than when the command was wrapped, the mount is refused), no command is started: the helper exits 1, and its one line on stderr starts with `apply-seccomp: --hold-link : `. `seccomp.holdsLinks: false` turns holding off. +- On Linux `SandboxManager.getLinuxUnheldLinks()` returns each link that should be held and is not, as `{ path, reason }`, to show or to look at again after the command; the only other sign is a line under `SRT_DEBUG`. What such a link leads to is denied all the same, and the command can replace the link. The reasons: `no_helper` (`allowAllUnixSockets`, or no binary for the architecture), `told_no` (`seccomp.holdsLinks` is false), `not_told` (the helper is named by `seccomp.applyPath` and `seccomp.holdsLinks` is not set), `older_helper` (the helper the library found for itself is built from older sources), `no_answer` (that helper's file could not be read). A link is listed by the latest wrap that met it. `reset()` and `updateConfig()` empty the list, and a wrap still in flight then lists its links afterwards. +- On Linux the held links' paths and targets stay on the command line (see the note on large profiles below): some thousand of them reach the limit of one argument, and the `command_too_long` refusal says how many bytes are theirs. +- Not covered: a mandatory deny name _below_ the working directory that is itself a link (the Linux scan does not see it; the macOS pattern keeps the name and says nothing of what it leads to). On Linux, a link on the way to what a `denyRead` pattern matches, other than the match itself (a `denyWrite` pattern is skipped there as a whole), and a writer outside the sandbox that changes a link while the command is being wrapped. +- Not reported on Linux: the violation monitor judges by the configured names, so a refused attempt on a kept link or directory is no violation in its store. + **Read-side rules (Linux):** an entry is matched by the name it is, not by what it points at. - An `allowRead` entry re-allows the name it names, so a link planted at an allowed path cannot re-open a denied one. One that is itself a symlink is bound back at its own name, from the target it was checked against: the target's own path stays hidden, and the target's contents are reachable only through the name. A `denyRead` entry or a credential mask that overlaps that target — at it, inside it, or covering it — wins over the carve-out, which is then not restored at all: the name is absent inside the sandbox rather than serving what the deny hides. The denied directory the carve-out is being restored into is not such an overlap, nor is anything above it: those are what the carve-out is an exception to. Neither is an entry that denies nothing — one naming a path that is not there, or a file an `allowRead` entry lifts. @@ -1184,14 +1212,14 @@ Filesystem restrictions are enforced at the OS level: - No mount point is made where none can be: in a directory that refuses this user, when the refusal is not this user's to lift (another user's directory it may not write, a read-only file system, an immutable directory). bubblewrap could not create the path there, and would stop every command from starting; the sandboxed command cannot create it either, so the deny holds without a mount. The directories above the path are kept from being moved aside as for any other deny. A directory of your own that only its mode closes keeps its mount point, because the command could open it with `chmod`. Should somebody else lift the refusal while a command runs (the owner, an administrator, a remount), that command can create the path; the next one gets its mount point. - On a Windows drive under WSL 2 (`/mnt/c`, a 9p mount) the Windows ACL decides what can be created, and Linux is told nothing of it: every folder looks like yours and writable. The only way to know is to try, so there, and only there, the library makes the mount point itself while it wraps the command, instead of leaving it to bubblewrap when the command runs: the same empty file or directory, at the same path, removed by the same clean-up. It is on the drive from the wrap on, also for a command that is never run, until `cleanupAfterCommand()` or `reset()`. A file deny in a folder that takes subfolders but no files (`C:\`) is covered by an empty directory. If nothing can be made and the folder refuses even to be given the mode it has, no mount point is made, as above. -**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. +**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc//fd/ /usr/bin/bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only. - The profile is never given a name, so nothing can be put in its place between the wrap and the execution — not a command the process sandboxed, not a sandbox another process of the same user started with tmpdir writable, not a rename of a directory above `TMPDIR`. Every sandbox this library starts has its own PID namespace and a fresh `/proc`, so none of them can reach `/proc/` either. - Not covered: another process of the same user running outside a sandbox can read a pending profile through `/proc`. It can already read the wrapping process's memory, so this gives it nothing new. - The string must be run while the process that produced it is alive, and before the runtime cleans up after that command (`cleanupAfterCommand()`), which is when the profile is released. - The profile needs a directory that takes an `O_TMPFILE` file — `os.tmpdir()`, else `/dev/shm` — and a readable `/proc/self/fd`. Without them an over-long profile is refused at wrap time with the reason; there is no fallback to a named file. Profiles that fit the command line do not use any of this. - bubblewrap parses at most 9000 arguments (about 3000 mounts). A profile past that, or a command too long for one argument by itself, fails at wrap time with an error. The [mandatory deny paths](#mandatory-deny-paths-auto-protected-files) count too, and no list in the configuration holds them: a git repository directly inside a writable working directory takes four mounts (its hooks, its config, and the two directories above them), so some 730 repositories reach the limit by themselves. The error says how many of the arguments are theirs. There are fewer when the command runs from a directory with fewer repositories in it, or when `allowWrite` names fewer of them; a lower `mandatoryDenySearchDepth` leaves the deeper ones unprotected. -- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in. A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. +- Every one of these wrap-time refusals is a `LinuxSandboxProfileError`, exported from the package root, with a `LinuxSandboxProfileErrorCode` on `.code` to tell the cases apart; branch on `.code` rather than on the message. They say the configuration expands to a profile this host cannot run, except `command_too_long` and `nul_in_path`, which also fire on what the embedding program passed in, and `deny_glob_too_large`, which says the read patterns could not be expanded within their budget (see "Path Syntax (Linux)"). A wrap that threw has already released what it held: do not call `cleanupAfterCommand()` for it, or a sandbox still running loses its mount points. ### Mandatory Deny Paths (Auto-Protected Files) @@ -1207,9 +1235,9 @@ Certain sensitive files and directories are **always blocked from writes**, even - IDE directories: `.vscode/`, `.idea/` - Claude config directories: `.claude/commands/`, `.claude/agents/` -- Git hooks and config: `.git/hooks/`, `.git/config`. Where the working directory is a worktree or a submodule, its `.git` is a file: on Linux that file is blocked, so it can be read but not changed, moved or removed. +- Git hooks and config: `.git/hooks/`, `.git/config`. Where the working directory is a worktree or a submodule, its `.git` is a file: on Linux and Windows (srt-win) that file is blocked, so it can be read but not changed, moved or removed. -These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: +These paths are blocked automatically, on every platform (on Windows by the srt-win backend only) - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail: ```bash $ srt 'echo "malicious" >> .bashrc' @@ -1221,6 +1249,15 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' **Note (Linux):** A mandatory deny path that does not exist yet is blocked as well. bubblewrap covers it with a read-only `/dev/null`, or mounts an empty read-only directory at the first missing intermediate component, and those host mount points are removed by `cleanupAfterCommand()` — see "Write denies on paths that do not exist yet (Linux)" above. macOS uses glob patterns, which cover existing and new files alike. +**Note (Windows, srt-win backend):** Only what is there is blocked: a name that does not exist yet can be created, and is blocked from the next command on. + +- The names are looked for at `initialize()` in the working directory of the process, and at every command in the `cwd` given to `wrapWithSandboxArgv()`: each name down to `mandatoryDenySearchDepth` levels below it (`a\b\.bashrc`, `a\b\.git` at the default of 3), `node_modules` apart. Those under an `allowWrite` root are denied. A command writes an access list only for a name that is new, or that is another file than the one denied (git and most editors write a file by putting a new one in its place); until that command, such a file is writable. +- A symbolic link or junction is passed over, at one of these names and on the way to one: it is neither followed nor denied. +- Where `.git` is a file, `hooks` and `config` are also blocked in the directories it leads to, as far as they lie under an `allowWrite` root: the git directory of a linked worktree and the repository it belongs to, or `.git\modules\` for a submodule. +- They are denies beneath a kept root like any other (see "Write grants on `allowWrite` roots are kept between sessions"): kept with it, and not removed by a later session. So `.git\config`, once blocked, stays blocked for a later session with `filesystem.allowGitConfig` until `srt-win acl forget `. +- Every directory between a path the session denies (`denyRead`, `denyWrite`, these names) and the kept root above it can be neither renamed nor removed from inside the sandbox, so that the name cannot be moved aside and made anew. Of kept roots one inside another the outermost counts, whether or not this session holds it, and each is forgotten for itself. What is in those directories is as writable as before. The first deny in a tree has Windows rewrite the access list of everything beneath the root, once, as the root's own grant did. +- A file with a second hard link cannot be denied: `initialize()`, or the wrap, fails with `acl_grant_failed` and names it. A sandboxed command can bring that about with a file it makes itself (`.mcp.json` and a hard link to it), and no command runs there until one of the two names is removed from outside the sandbox. With a `.git` file of its own making it can likewise have `hooks` and `config` denied in any directory under the root. + **Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected. With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`). They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not. @@ -1327,7 +1364,7 @@ Users should be aware of potential risks that come from allowing broad domains l - Privilege Escalation via Unix Sockets: The `allowUnixSockets` configuration can inadvertently grant access to powerful system services that could lead to sandbox bypasses. For example, if it is used to allow access to `/var/run/docker.sock` this would effectively grant access to the host system through exploiting the docker socket. Users are encouraged to carefully consider any unix sockets that they allow through the sandbox. -- Filesystem Permission Escalation: Overly broad filesystem write permissions can enable privilege escalation attacks. Allowing writes to directories containing executables in `$PATH`, system configuration directories, or user shell configuration files (`.bashrc`, `.zshrc`) can lead to code execution in different security contexts when other users or system processes access these files. +- Filesystem Permission Escalation: Overly broad filesystem write permissions can enable privilege escalation attacks. Allowing writes to directories containing executables in `$PATH`, system configuration directories, or user shell configuration files (`.bashrc`, `.zshrc`) can lead to code execution in different security contexts when other users or system processes access these files. The library's own host-side helpers are not taken from such a directory (see "Where these programs are taken from"), but anything else run from it is. - Where This Package Is Installed: Install it outside every allowed write path (a global prefix outside them, or `npx` run from outside the project). A copy in `/node_modules/` with the project writable can be rewritten by a sandboxed command, and the next `srt` there runs the result unsandboxed. `checkDependencies()` returns a warning for a copy placed like that, also logged under `SRT_DEBUG`. A copy compiled into an application is unaffected. - Linux Sandbox Strength: The Linux implementation provides strong filesystem and network isolation but includes an `enableWeakerNestedSandbox` mode that enables it to work inside of Docker environments without privileged namespaces. This option considerably weakens security and should only be used in cases where additional isolation is otherwise enforced. - Weaker Network Isolation (macOS): The `enableWeakerNetworkIsolation` option re-enables access to `com.apple.trustd.agent`, which is needed for Go programs to verify TLS certificates via the macOS Security framework. This opens a potential data exfiltration vector through the trustd service and should only be enabled when Go TLS verification is required (e.g., when using `httpProxyPort` with a MITM proxy and custom CA). diff --git a/package-lock.json b/package-lock.json index 6b5676800..e5c04fa70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", @@ -733,13 +733,13 @@ } }, "node_modules/@types/bun": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.2.tgz", - "integrity": "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.2.tgz", + "integrity": "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.2" + "bun-types": "1.4.2" } }, "node_modules/@types/estree": { @@ -764,13 +764,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-forge": { @@ -783,17 +783,6 @@ "@types/node": "*" } }, - "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", @@ -1606,16 +1595,13 @@ } }, "node_modules/bun-types": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.2.tgz", - "integrity": "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.2.tgz", + "integrity": "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" - }, - "peerDependencies": { - "@types/react": "^19" } }, "node_modules/call-bind": { @@ -1786,14 +1772,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", - "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5100,9 +5078,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 7cddf830b..625a68619 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "build:srt-proxy-node": "bun build src/proxy-main.ts --target=node --format=esm --outfile dist/srt-proxy.mjs", "clean": "rm -rf dist", "test": "bun test", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts test/utils/real-path.test.ts test/sandbox/default-write-paths.test.ts test/sandbox/rules-through-links-on-every-runtime.test.ts test/sandbox/tls-terminate-tunnel-ends.test.ts", + "typecheck": "tsc && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", "format": "prettier --write 'src/**/*.ts' --cache --log-level warn", @@ -38,8 +39,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", diff --git a/src/index.ts b/src/index.ts index f3f9f7874..472a6cbc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,11 +52,15 @@ export type { // Platform-specific utilities export type { SandboxViolationEvent } from './sandbox/macos-sandbox-utils.js' +export type { SkippedHostHelper } from './sandbox/host-helpers.js' export { type SandboxDependencyCheck, LinuxSandboxProfileError, type LinuxSandboxProfileErrorCode, + type LinuxUnheldLink, + type LinuxUnheldLinkReason, } from './sandbox/linux-sandbox-utils.js' +export type { GlobWalkBudgetError } from './sandbox/sandbox-utils.js' // Windows install/status API export { diff --git a/src/node-tls.d.ts b/src/node-tls.d.ts new file mode 100644 index 000000000..2839445da --- /dev/null +++ b/src/node-tls.d.ts @@ -0,0 +1,9 @@ +// The Bun type package redeclares `connect` in 'node:tls' in terms of names +// that @types/node 22 declares in 'tls' alone. Unresolved, they leave it +// taking and returning an untyped value. +declare module 'node:tls' { + import * as tls from 'tls' + export import ConnectionOptions = tls.ConnectionOptions + export import KeyObject = tls.KeyObject + export import TLSSocket = tls.TLSSocket +} diff --git a/src/sandbox/generate-seccomp-filter.ts b/src/sandbox/generate-seccomp-filter.ts index 1212a8e89..d7a4b8276 100644 --- a/src/sandbox/generate-seccomp-filter.ts +++ b/src/sandbox/generate-seccomp-filter.ts @@ -4,8 +4,15 @@ import * as fs from 'node:fs' import { exec, execSync } from 'node:child_process' import { homedir } from 'node:os' import { logForDebugging } from '../utils/debug.js' +import { + hostSearchPath, + isOutside, + writablePlaces, + type WritablePlaces, +} from './host-helpers.js' -// Cache for path lookups (key: explicit path or empty string, value: resolved path or null) +// Cache for path lookups (key: explicit path or empty string, and the places +// it was judged by; value: resolved path or null) const applySeccompPathCache = new Map() // Cache for global npm paths (computed once per process) @@ -15,6 +22,20 @@ let pendingGlobalNpmPaths: Promise | null = null const NPM_ROOT_COMMAND = 'npm root -g' const NPM_ROOT_TIMEOUT_MS = 5000 +/** `process.env` for `npm root -g`, which runs on the host: npm, and the node + * that runs it, are looked up outside `writable`. */ +function npmEnvironment(writable: WritablePlaces): NodeJS.ProcessEnv { + return process.platform === 'win32' + ? process.env + : { ...process.env, PATH: hostSearchPath(writable, ['npm', 'node']) } +} + +/** For tests only: forget what `npm root -g` answered. */ +export function resetGlobalNpmPathsForTesting(): void { + cachedGlobalNpmPaths = null + pendingGlobalNpmPaths = null +} + /** * Get paths to check for globally installed @anthropic-ai/sandbox-runtime package. * This is used as a fallback when the binaries aren't bundled (e.g., native builds). @@ -23,7 +44,9 @@ const NPM_ROOT_TIMEOUT_MS = 5000 * called in a process unless {@link getGlobalNpmPathsAsync} has already * filled the cache; initialize() goes through the async variant. */ -export function getGlobalNpmPaths(): string[] { +export function getGlobalNpmPaths( + writable: WritablePlaces = writablePlaces(undefined), +): string[] { if (cachedGlobalNpmPaths) return cachedGlobalNpmPaths let npmRoot: string | undefined @@ -32,6 +55,7 @@ export function getGlobalNpmPaths(): string[] { encoding: 'utf8', timeout: NPM_ROOT_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'ignore'], + env: npmEnvironment(writable), }) } catch { // npm not available or failed @@ -47,7 +71,9 @@ export function getGlobalNpmPaths(): string[] { * timed-out npm yields just the static fallback locations, as the sync * variant does. */ -export function getGlobalNpmPathsAsync(): Promise { +export function getGlobalNpmPathsAsync( + writable: WritablePlaces = writablePlaces(undefined), +): Promise { if (cachedGlobalNpmPaths) return Promise.resolve(cachedGlobalNpmPaths) if (pendingGlobalNpmPaths) return pendingGlobalNpmPaths @@ -55,7 +81,11 @@ export function getGlobalNpmPathsAsync(): Promise { try { const child = exec( NPM_ROOT_COMMAND, - { encoding: 'utf8', timeout: NPM_ROOT_TIMEOUT_MS }, + { + encoding: 'utf8', + timeout: NPM_ROOT_TIMEOUT_MS, + env: npmEnvironment(writable), + }, (err, stdout) => resolve(err ? undefined : stdout), ) // Match execSync's closed stdin: npm never waits on it. @@ -190,11 +220,14 @@ function getLocalSeccompPaths(filename: string): string[] { * @param seccompBinaryPath - Optional explicit path to the apply-seccomp binary. If provided * and exists, it will be used. If not provided, falls back to searching local paths and * then global npm install (for native builds where vendor directory isn't bundled). + * @param writable - Where the sandboxed command may write: no global install is taken from + * there, nor the npm that is asked for one. Omitted: as for a policy that is not known yet. */ export function getApplySeccompBinaryPath( seccompBinaryPath?: string, + writable: WritablePlaces = writablePlaces(undefined), ): string | null { - const cacheKey = seccompBinaryPath ?? '' + const cacheKey = [seccompBinaryPath, ...writable].join('\0') if (applySeccompPathCache.has(cacheKey)) { return applySeccompPathCache.get(cacheKey)! } @@ -203,7 +236,7 @@ export function getApplySeccompBinaryPath( const result = local !== undefined ? local - : findGlobalApplySeccompPath(getGlobalNpmPaths()) + : findGlobalApplySeccompPath(getGlobalNpmPaths(writable), writable) applySeccompPathCache.set(cacheKey, result) return result } @@ -216,8 +249,9 @@ export function getApplySeccompBinaryPath( */ export async function getApplySeccompBinaryPathAsync( seccompBinaryPath?: string, + writable: WritablePlaces = writablePlaces(undefined), ): Promise { - const cacheKey = seccompBinaryPath ?? '' + const cacheKey = [seccompBinaryPath, ...writable].join('\0') if (applySeccompPathCache.has(cacheKey)) { return applySeccompPathCache.get(cacheKey)! } @@ -226,7 +260,10 @@ export async function getApplySeccompBinaryPathAsync( const result = local !== undefined ? local - : findGlobalApplySeccompPath(await getGlobalNpmPathsAsync()) + : findGlobalApplySeccompPath( + await getGlobalNpmPathsAsync(writable), + writable, + ) applySeccompPathCache.set(cacheKey, result) return result } @@ -280,7 +317,10 @@ function findLocalApplySeccompPath( /** * Fallback: check global npm install (for native builds without bundled vendor) */ -function findGlobalApplySeccompPath(globalBases: string[]): string | null { +function findGlobalApplySeccompPath( + globalBases: string[], + writable: WritablePlaces, +): string | null { const arch = getVendorArchitecture() if (!arch) return null @@ -292,7 +332,7 @@ function findGlobalApplySeccompPath(globalBases: string[]): string | null { arch, 'apply-seccomp', ) - if (fs.existsSync(binaryPath)) { + if (fs.existsSync(binaryPath) && isOutside(binaryPath, writable)) { logForDebugging( `[SeccompFilter] Found apply-seccomp binary in global install: ${binaryPath} (${arch})`, ) diff --git a/src/sandbox/glob-pattern.ts b/src/sandbox/glob-pattern.ts index 391304f60..6078cd823 100644 --- a/src/sandbox/glob-pattern.ts +++ b/src/sandbox/glob-pattern.ts @@ -18,7 +18,7 @@ export enum GlobTokenKind { Globstar = 'globstar', /** `**` with the `/` after it: any number of directories. */ GlobstarSlash = 'globstarSlash', - /** `[abc]`, `[a-z]`: one character of a set. */ + /** `[abc]`, `[a-z]`: one character of a set. `[!abc]`: one outside it. */ Set = 'set', } @@ -26,9 +26,11 @@ export type GlobToken = | { kind: GlobTokenKind.Text; text: string } | { kind: GlobTokenKind.Set - /** What is written between the brackets. */ + /** What is written between the brackets, after a `!` or `^` that negates. */ members: string - /** `[+-9]` does: a `/` lies between the two. */ + /** `[!abc]`, `[^abc]`: a character that is no member, and no `/`. */ + negated: boolean + /** `[+-9]` does: a `/` lies between the two. A negated set never does. */ matchesSeparator: boolean } | { kind: GlobTokenKind.Separator | GlobTokenKind.AnyChar | WildcardKind } @@ -66,11 +68,12 @@ const SEPARATOR = '/'.charCodeAt(0) * is a pattern, and no character escapes another: * * - A set runs from a `[` to the first `]` after it, be that the next - * character. All between the two are members, a `*`, a `?`, a `/` or another - * `[` too, and `^` and `!` do not negate. + * character. A `!` or a `^` straight after the `[` negates it. All else + * between the two are members, a `*`, a `?`, a `/` or another `[` too. * - Brackets that spell no set (see {@link setRanges}) are text, as POSIX - * reads a `[` that opens none, and what is between them keeps its meaning. - * So are a `[` that nothing closes and a `]` that nothing opens. + * reads a `[` that opens none, and what is between them keeps its meaning: + * `[!]` and `[^]` have no members. So are a `[` that nothing closes and a + * `]` that nothing opens. * - Of a run of `*`, the last two go with a `/` that follows. Two or more of * the rest are a `**`. */ @@ -151,10 +154,11 @@ export function parseGlob(pattern: string): GlobToken[] { i++ break } - const members = pattern.slice(i + 1, close) + const negated = pattern[i + 1] === '!' || pattern[i + 1] === '^' + const members = pattern.slice(i + (negated ? 2 : 1), close) const ranges = setRanges(members) if (ranges === undefined) { - noSets.push(`[${members}]`) + noSets.push(pattern.slice(i, close + 1)) bracketsAreTextUntil = close + 1 i++ break @@ -163,9 +167,10 @@ export function parseGlob(pattern: string): GlobToken[] { tokens.push({ kind: GlobTokenKind.Set, members, - matchesSeparator: ranges.some( - ([from, to]) => from <= SEPARATOR && SEPARATOR <= to, - ), + negated, + matchesSeparator: + !negated && + ranges.some(([from, to]) => from <= SEPARATOR && SEPARATOR <= to), }) textStart = i = close + 1 break @@ -189,6 +194,69 @@ export function escapeRegexLiteral(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +const DASH = '-'.charCodeAt(0) +const CARET = '^'.charCodeAt(0) + +/** + * The regular expression for a set, written the way JavaScript and the regex + * engine of a macOS sandbox profile read alike. What that engine does with a + * set, as measured on macOS 14, 15 and 26, and what is done about it: + * + * - A backslash is a member (`[^/\$]` leaves out `\` as well as `$`). So a + * member is written as it is, and a backslash doubled: one `\` to both. + * - `[.`, `[:` and `[=` open a POSIX element, and the whole profile is refused + * where nothing closes it (`[[.]`). So the members go in ascending order, + * in which none of the three follows a `[`. + * - A `-` is a member only where it is the first: a set that ends in one + * (`[a-]`) and a range that starts at one (`[--9]`) are refused. So one + * that stands for itself, or at either end of a range, goes first, and such + * a range is the `-` and the rest of it, from `.` or up to `,`. + * - In a rule with the tail of {@link denyGlobRegex}, a negated set in which + * two neighbours are written apart (`[^/0-9]`, `[^/abc]`) is read as `[^/]`. + * So there they are written as one range (`[^/-9]`, `[^/a-c]`). That leaves + * a `-` beside a `,` or a `.`, which the line above keeps apart. + * + * In a set that is not negated a `^` goes last, where neither takes it to + * negate. Beyond ASCII nothing is moved: sorted by code unit, the halves of + * two characters outside the basic plane would part. + */ +function setToRegex(members: string, negated: boolean): string { + const ranges = setRanges(members) ?? [] + if (negated) ranges.push([SEPARATOR, SEPARATOR]) + const isAscii = ([, to]: (typeof ranges)[number]): boolean => to < 0x80 + const merged: typeof ranges = [] + for (const [from, to] of ranges.filter(isAscii).sort(([a], [b]) => a - b)) { + const last = merged[merged.length - 1] + if (last !== undefined && from <= last[1] + (negated ? 1 : 0)) { + last[1] = Math.max(last[1], to) + } else { + merged.push([from, to]) + } + } + const member = (code: number): string => + String.fromCharCode(code).replace('\\', '\\\\') + let dash = '' + let caret = '' + let rest = '' + for (let [from, to] of [...merged, ...ranges.filter(r => !isAscii(r))]) { + if (from === DASH || to === DASH) { + dash = '-' + if (from === to) continue + if (from === DASH) from++ + else to-- + } + if (from === CARET && !negated) { + caret = '^' + if (from === to) continue + from++ + } + rest += from === to ? member(from) : `${member(from)}-${member(to)}` + } + const body = dash + rest + caret + // Alone in its set, the `-` is as well written without one. + return body === '-' ? body : `[${negated ? '^' : ''}${body}]` +} + /** The regular expression for what `token` matches. */ export function globTokenToRegex(token: GlobToken): string { switch (token.kind) { @@ -205,8 +273,7 @@ export function globTokenToRegex(token: GlobToken): string { case GlobTokenKind.GlobstarSlash: return '(.*/)?' case GlobTokenKind.Set: - // A `-` stays what it is to the set, and the rest are its members. - return `[${token.members.replace(/[.^$+{}()|\\]/g, '\\$&')}]` + return setToRegex(token.members, token.negated) } } @@ -221,6 +288,7 @@ export function globTokenToRegex(token: GlobToken): string { * - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/) * - ? matches any single character except / (e.g., file?.txt matches file1.txt) * - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt) + * - [!abc] and [^abc] match any character outside the set, never a `/` * * Whatever a pattern spells, what is returned compiles, so that no entry keeps * a sandbox from starting (see {@link parseGlob}). Only its size can be more diff --git a/src/sandbox/host-helpers.ts b/src/sandbox/host-helpers.ts new file mode 100644 index 000000000..4251e55a0 --- /dev/null +++ b/src/sandbox/host-helpers.ts @@ -0,0 +1,227 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { getPlatform } from '../utils/platform.js' +import { wayTo } from '../utils/real-path.js' +import { + isExecutableFile, + isPathQualified, + pathCandidates, + whichSync, +} from '../utils/which.js' +import { + containsGlobChars, + getDefaultWritePaths, + globBaseDirIsRoot, + globPatternBaseDir, + isAtOrUnder, + normalizePathForSandbox, + workingDirectory, +} from './sandbox-utils.js' + +/** A file the search found and would not use, and why. */ +export type SkippedHostHelper = { + path: string + reason: string + /** The allowed write path that covers it, when that is the reason. */ + writePath?: string +} + +export type HostHelperSearch = { + /** The helper to run, by absolute path; null when none may be used. */ + path: string | null + /** What was found before it, or instead of it, and passed over. */ + skipped: SkippedHostHelper[] +} + +/** What {@link writablePlaces} makes of a policy's write paths. */ +export type WritablePlaces = readonly string[] + +/** + * Where a sandboxed command may write, in the form a location is compared + * against: each allowed write path (`writeRootsOf` a write config) as + * `normalizePathForSandbox` spells it, symlinks resolved. On Linux each is a + * name, as it is to the wrap; elsewhere one that reads as a pattern counts by + * the directory it starts from. `denyWithinAllow` is not subtracted: passing + * over more is the safe side. + * + * - `'anywhere'`, said outright, or a list that names `/`: nowhere, since no + * place is then better than another. A file's permissions are not judged. + * - `undefined`, the policy not being known yet: the working directory and the + * default write paths, which nearly every policy allows. + * - A link that leads out of its own place is left as spelled, and the wrap + * binds nothing for it. The command can make such a link wherever one entry + * lies inside another (`/dist -> /`), so it must neither switch + * the comparison off nor widen it. + */ +export function writablePlaces( + allowedWritePaths: readonly string[] | 'anywhere' | undefined, +): WritablePlaces { + if (allowedWritePaths === 'anywhere') return [] + const places = new Set() + const literal = getPlatform() === 'linux' + const cwd = workingDirectory() + for (const allowed of allowedWritePaths ?? [ + ...(cwd === undefined ? [] : [cwd]), + ...getDefaultWritePaths(), + ]) { + const normalized = normalizePathForSandbox(allowed, { literal }) + // `~` under an empty $HOME is the empty spelling, which names no place. + if (!path.isAbsolute(normalized)) continue + const place = + !literal && containsGlobChars(normalized) + ? globPatternBaseDir(normalized) + : normalized + if (globBaseDirIsRoot(place)) return [] + places.add(place) + } + return [...places] +} + +/** + * Why the absolute path `file` must not be run on the host, or `undefined` + * when it may. Every place a lookup of it passes through counts, not only the + * outcome: `file` as spelled (which covers the `PATH` entry it lies beneath), + * each symbolic link followed, each directory a link's target goes up from + * again, and the file it resolves to. One the command may write can lead + * elsewhere by the time the helper is run. + */ +function refusalFor( + file: string, + writable: WritablePlaces, +): Omit | undefined { + if (writable.length === 0) return undefined + const places = [file] + const note = (place: string): unknown => places.push(place) + let followed = false + try { + const { real, notThere } = wayTo(file, { link: note, left: note }) + followed = notThere === undefined + places.push(real) + } catch { + // Judged by as far as it could be followed. + } + for (const place of places) { + const writePath = writable.find(w => isAtOrUnder(place, w)) + if (writePath !== undefined) { + return { + reason: `${place === file ? '' : `leads through ${place}, `}inside the allowed write path ${writePath}`, + writePath, + } + } + } + return followed ? undefined : { reason: 'it could not be followed to a file' } +} + +/** Whether `file` lies, and is reached, where the command cannot write. */ +export function isOutside(file: string, writable: WritablePlaces): boolean { + return refusalFor(file, writable) === undefined +} + +/** + * Find `name`, a program that runs with more authority than the wrapped + * command: on the host (bubblewrap, the socat bridges, the ripgrep scan), or + * inside the sandbox ahead of the seccomp filter (the relays, the shell that + * starts the helper). A file the policy lets the command write is never + * taken: it is whatever an earlier wrapped command left there. + * + * `PATH` is searched as it stands, even when a writable directory leads it + * (`/node_modules/.bin` under `npx`), and nothing is run to search + * it. What is found in a writable place, or through a relative `PATH` entry, + * is passed over and recorded. A name with a directory part is a directive, + * and taken as given. + * + * `path` is `null` when no candidate may be used. The caller refuses to go + * on; falling back to the bare name would hand the choice back to `PATH`. + * POSIX only. + */ +export function findHostHelper( + name: string, + writable: WritablePlaces, +): HostHelperSearch { + if (isPathQualified(name)) return { path: whichSync(name), skipped: [] } + const skipped: SkippedHostHelper[] = [] + for (const { entry, file } of pathCandidates(name)) { + if (!isExecutableFile(file)) continue + const refusal = path.isAbsolute(entry) + ? refusalFor(file, writable) + : { reason: `the PATH entry '${entry}' is relative` } + if (refusal === undefined) return { path: file, skipped } + skipped.push({ path: file, ...refusal }) + } + return { path: null, skipped } +} + +/** + * The PATH for a program the library starts on the host that does look-ups of + * its own (`npm`, a script that finds `node` by name): this process's PATH + * less every entry a host helper would not be taken from. An entry is judged + * as a directory and by its copy of each of `programs`, the names the child + * looks up: a link in a directory nobody can write may lead to a file + * somebody can. Never empty: a shell reads an empty PATH as the current + * directory, so with no entry left it is `/dev/null`, which holds nothing. + * POSIX only. + */ +export function hostSearchPath( + writable: WritablePlaces, + programs: readonly string[], +): string { + const acceptable = (entry: string): boolean => + path.isAbsolute(entry) && + isOutside(entry, writable) && + programs.every(program => { + const file = `${entry}/${program}` + // Whatever is there is judged, executable or not, a dangling link + // included: the command could supply the rest later. + try { + fs.lstatSync(file) + } catch { + return true + } + return isOutside(file, writable) + }) + return ( + (process.env.PATH ?? '').split(':').filter(acceptable).join(':') || + '/dev/null' + ) +} + +/** + * The refusal for a search that found nothing to use: which helper, what was + * passed over and why, and that `option` lifts it. INVARIANT: it opens with + * " was not found on PATH", and of a helper that is nowhere to be + * found the dependency check says `nowhere`, its words as ever. It hands out + * strings only, and embedders tell its errors apart by them. + */ +export function describeUnavailableHostHelper( + helper: string, + option: string, + { skipped }: HostHelperSearch, + nowhere = isPathQualified(helper) + ? `${helper} is not an executable file` + : `${helper} was not found on PATH`, +): string { + if (skipped.length === 0) return nowhere + return ( + `${helper} was not found on PATH outside the paths the sandboxed command may write. ` + + `Passed over: ${skipped.map(s => `${s.path} (${s.reason})`).join('; ')}. ` + + `Install it outside those paths, put the directory that holds it on PATH, or name it by its path with ${option}.` + ) +} + +/** + * The dependency-check warning for `file`, a helper the operator named + * outright through `option`, when the command may write it by the same test as + * the search; `undefined` otherwise, and for a bare name, which names no file. + * The file is still used as given. + */ +export function writableNamedHelperWarning( + option: string, + file: string | undefined, + writable: WritablePlaces, +): string | undefined { + const refusal = + file && isPathQualified(file) && refusalFor(path.resolve(file), writable) + return refusal && refusal.writePath !== undefined + ? `${option} ${file} ${refusal.reason.replace(/^inside/, 'is inside')}: the sandboxed command can replace that file` + : undefined +} diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index bc1f67aa6..e25ce25b2 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -526,8 +526,9 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { } } - // Handle CONNECT requests for HTTPS traffic - server.on('connect', async (req, socket, head) => { + // Handle CONNECT requests for HTTPS traffic. `head` is widened from the + // listener's NonSharedBuffer: it is reassigned below from socket reads. + server.on('connect', async (req, socket, head: Buffer) => { // Attach error handler immediately to prevent unhandled errors socket.on('error', err => { logForDebugging(`Client socket error: ${err.message}`, { level: 'error' }) diff --git a/src/sandbox/java-proxy-agent.ts b/src/sandbox/java-proxy-agent.ts index c8f132191..e0e0e1218 100644 --- a/src/sandbox/java-proxy-agent.ts +++ b/src/sandbox/java-proxy-agent.ts @@ -24,6 +24,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { logForDebugging } from '../utils/debug.js' import { getGlobalNpmPathsAsync } from './generate-seccomp-filter.js' +import { writablePlaces, type WritablePlaces } from './host-helpers.js' export const JAVA_PROXY_AGENT_JAR_NAME = 'srt-proxy-agent.jar' @@ -44,12 +45,14 @@ const jarPathCache = new Map() */ export async function getJavaProxyAgentJarPathAsync( explicitPath?: string, + writable: WritablePlaces = writablePlaces(undefined), ): Promise { const key = explicitPath ?? '' const cached = jarPathCache.get(key) if (cached !== undefined) return cached const found = - findLocalJar(explicitPath) ?? findGlobalJar(await getGlobalNpmPathsAsync()) + findLocalJar(explicitPath) ?? + findGlobalJar(await getGlobalNpmPathsAsync(writable)) jarPathCache.set(key, found) return found } diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 66f9db747..5abe58cd5 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1,7 +1,6 @@ import { quote } from '../utils/shell-quote.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf, wayTo } from '../utils/real-path.js' -import { whichSync } from '../utils/which.js' +import { realPathOf, wayPastMissingNames } from '../utils/real-path.js' import { randomBytes } from 'node:crypto' import * as fs from 'fs' import { spawn, spawnSync } from 'node:child_process' @@ -10,7 +9,14 @@ import { endianness, tmpdir } from 'node:os' import path, { join } from 'node:path' import { ripGrep, RipgrepError, type RipgrepConfig } from '../utils/ripgrep.js' import { buildJavaToolOptions } from './java-proxy-agent.js' -import { readNamesOf, writeNamesOf } from './path-entries.js' +import { readNamesOf, writeNamesOf, writeRootsOf } from './path-entries.js' +import { + describeUnavailableHostHelper, + findHostHelper, + writableNamedHelperWarning, + writablePlaces, + type WritablePlaces, +} from './host-helpers.js' import { generateProxyEnvVars, buildPosixGitSafeDirEnv, @@ -20,6 +26,7 @@ import { encodeSandboxedCommand, attributionKeyFor, DANGEROUS_FILES, + followLinks, isAbsenceErrno, isAtOrUnder, isStrictlyUnder, @@ -95,10 +102,16 @@ export interface LinuxSandboxParams { gitSafeDirectories?: readonly string[] /** Custom seccomp binary paths */ seccompConfig?: SeccompConfig - /** Absolute path to the bwrap binary (default: resolve "bwrap" via PATH) */ + /** Absolute path to bwrap (default: "bwrap" as `findHostHelper` finds it) */ bwrapPath?: string - /** Absolute path to the socat binary (default: resolve "socat" via PATH) */ + /** Absolute path to the socat binary (default: "socat", found likewise) */ socatPath?: string + /** + * Where commands wrapped before this one could write, beyond `writeConfig`: + * the session's places, when `writeConfig` is one call's own. No host helper + * is taken from there either. + */ + alsoWritable?: WritablePlaces /** Filesystem unix socket bound by the Linux violation monitor. When set, * the socket is bind-mounted into the sandbox and apply-seccomp is told * (via SRT_OBSERVE_SOCK) to install a USER_NOTIF observation filter and @@ -150,9 +163,6 @@ function findSymlinkInPath( return null } -/** As many missing names as one way is followed up from again. */ -const MAX_MISSING_NAMES_PASSED = 40 - /** * Canonicalize a deny path through symlinks before any mask or bind is * computed (resolve-before-mask). Deny paths regularly contain symlinked @@ -225,21 +235,11 @@ function resolveSymlinkedDenyPath( } catch { return rejoined // Not a symlink — nothing left to resolve. } - let why: unknown = 'it goes up from too many names that are not there' + let why: unknown try { - let onward = rejoined - for (let i = 0; i < MAX_MISSING_NAMES_PASSED; i++) { - const { real, rest, notThere } = wayTo(onward) - if ((notThere as NodeJS.ErrnoException | undefined)?.code === 'EACCES') { - return real || '/' - } - const names = rest.filter(name => name !== '' && name !== '.') - if (names[1] !== '..') { - return [real, ...names.slice(0, 2)].join('/') || '/' - } - alsoDenied?.push(`${real}/${names[0]}`) - onward = [real, ...names.slice(2)].join('/') - } + const { real, rest, shut, passed } = wayPastMissingNames(rejoined) + alsoDenied?.push(...passed) + return [real, ...(shut ? [] : rest.slice(0, 2))].join('/') || '/' } catch (err) { why = err } @@ -543,7 +543,8 @@ async function linuxGetMandatoryDenyPaths( ripgrepConfig: RipgrepConfig = { command: 'rg' }, maxDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, - abortSignal?: AbortSignal, + abortSignal: AbortSignal | undefined, + writable: WritablePlaces, ): Promise { const cwd = process.cwd() // Use provided signal or create a fallback controller @@ -599,7 +600,15 @@ async function linuxGetMandatoryDenyPaths( ], cwd, signal, - ripgrepConfig, + // The scan runs on the host. With no copy to be had it has failed. + { + ...ripgrepConfig, + command: requireHostHelper( + ripgrepConfig.command, + 'ripgrep.command', + writable, + ), + }, ) } catch (error) { // Stopped, it found nothing: the caller must not be handed a command @@ -959,6 +968,13 @@ function errorText(error: unknown): string { /** Why a bubblewrap profile could not be run. */ export type LinuxSandboxProfileErrorCode = + /** + * bubblewrap, socat or the shell has no copy on PATH outside the paths the + * command may write. The message names each copy passed over and why, and + * `.cause` holds the same: `{ helper, skipped: SkippedHostHelper[] }`. + * Lifted by such a copy on PATH, or by naming the program by its path. + */ + | 'host_helper_unavailable' /** More arguments than bubblewrap parses, on the line or through a file. */ | 'too_many_arguments' /** A mount path holds a NUL byte, which no carrier of arguments can hold. */ @@ -972,6 +988,12 @@ export type LinuxSandboxProfileErrorCode = | 'args_file_unavailable' /** The line does not fit one shell argument even with the mounts in a file. */ | 'command_too_long' + /** + * The read patterns could not be expanded within their budget, so which + * paths to hide is not known. The `GlobWalkBudgetError` on `.cause` names + * the pattern, the directory being listed and what had been spent. + */ + | 'deny_glob_too_large' /** * Thrown when a Linux bubblewrap profile cannot be run on this host: what the @@ -982,8 +1004,9 @@ export type LinuxSandboxProfileErrorCode = * (`cleanupAfterCommand()`, `cleanupBwrapMountPoints()`) for a wrap that * threw: it would release a second time, and a sandbox still running would * lose its mount points. Branch on `.code`, never on `.message`, which - * carries the sizes of the moment. Other wrap-time failures (a shell that is - * not on PATH, a bridge socket that is gone) are plain Errors. + * carries the sizes of the moment. Other wrap-time failures (a bridge socket + * that is gone) are plain Errors. One code says nothing of the profile: with + * `host_helper_unavailable` a program the wrap runs is not to be had. */ export class LinuxSandboxProfileError extends Error { readonly code: LinuxSandboxProfileErrorCode @@ -1009,6 +1032,27 @@ export class LinuxSandboxProfileError extends Error { } } +/** + * The path `findHostHelper` gives for `helper`, which `option` could name. + * Throws the typed refusal when there is none, never going on under the bare + * name. + */ +function requireHostHelper( + helper: string, + option: string, + writable: WritablePlaces, +): string { + const search = findHostHelper(helper, writable) + if (search.path === null) { + throw new LinuxSandboxProfileError( + 'host_helper_unavailable', + describeUnavailableHostHelper(helper, option, search), + { helper, skipped: search.skipped }, + ) + } + return search.path +} + /** * Thrown by the wrap when the host changed its working directory while the * mandatory denies were looked for. Nothing has been handed out or left on @@ -1035,7 +1079,13 @@ export class WorkingDirectoryChanged extends Error { function renderBwrapInvocation( bwrapBinary: string, bwrapArgs: string[], - mounts: { start: number; end: number; mandatoryDenyArgs: number }, + mounts: { + start: number + end: number + mandatoryDenyArgs: number + /** How much of the command word is the seccomp helper's `--hold-link`s. */ + holdWordsBytes: number + }, ): string { // Whose the arguments are and what lowers each share: the mandatory denies // follow from what the working directory holds, not from the configuration. @@ -1100,7 +1150,11 @@ function renderBwrapInvocation( closeBwrapArgsProfile(argsFd) throw new LinuxSandboxProfileError( 'command_too_long', - `Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})`, + `Sandboxed command is too long for one shell argument even with the mounts passed through a file (${viaArgsFileBytes} bytes; the limit here is ${limit})${ + mounts.holdWordsBytes > 0 + ? `. At least ${mounts.holdWordsBytes} of them are not the caller's: they name the symbolic links on the way to denied paths, for the seccomp helper to hold, and stay on the line (seccomp.holdsLinks: false leaves them unheld)` + : '' + }`, ) } logForDebugging( @@ -1269,6 +1323,12 @@ export type LinuxDependencyOptions = { seccompConfig?: SeccompConfig bwrapPath?: string socatPath?: string + /** + * Where the sandboxed command may write: bwrap and socat are looked for on + * PATH outside it, as the wrap does, and an explicit path inside it is + * warned about. Omitted: as for a policy that is not known yet. + */ + writable?: WritablePlaces } function isExecutable(p: string): boolean { @@ -1287,14 +1347,17 @@ export function getLinuxDependencyStatus( opts?: LinuxDependencyOptions, ): LinuxDependencyStatus { const { seccompConfig, bwrapPath, socatPath } = opts ?? {} + const writable = opts?.writable ?? writablePlaces(undefined) + const onPath = (helper: string): boolean => + findHostHelper(helper, writable).path !== null // argv0 mode: apply-seccomp is compiled into the caller's binary — skip // the on-disk lookup and trust that applyPath resolves inside bwrap. return { - hasBwrap: bwrapPath ? isExecutable(bwrapPath) : whichSync('bwrap') !== null, - hasSocat: socatPath ? isExecutable(socatPath) : whichSync('socat') !== null, + hasBwrap: bwrapPath ? isExecutable(bwrapPath) : onPath('bwrap'), + hasSocat: socatPath ? isExecutable(socatPath) : onPath('socat'), hasSeccompApply: seccompConfig?.argv0 ? true - : getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null, + : getApplySeccompBinaryPath(seccompConfig?.applyPath, writable) !== null, } } @@ -1305,9 +1368,26 @@ export function checkLinuxDependencies( opts?: LinuxDependencyOptions, ): SandboxDependencyCheck { const { seccompConfig, bwrapPath, socatPath } = opts ?? {} + const writable = opts?.writable ?? writablePlaces(undefined) const errors: string[] = [] const warnings: string[] = [] + // Found as the wrap will find it (see `findHostHelper`). A copy passed over + // is named, so the caller learns at start-up that it will not be run. + const onPath = ( + helper: string, + option: string, + notInstalled: string, + ): string | null => { + const search = findHostHelper(helper, writable) + if (search.path === null) { + errors.push( + describeUnavailableHostHelper(helper, option, search, notInstalled), + ) + } + return search.path + } + // An explicit override is a directive, not a hint — if it doesn't exist, // surface that rather than silently falling back to PATH. let usableBwrap: string | null = null @@ -1315,24 +1395,37 @@ export function checkLinuxDependencies( if (isExecutable(bwrapPath)) usableBwrap = bwrapPath else errors.push(`bubblewrap (bwrap) not executable at ${bwrapPath}`) } else { - usableBwrap = whichSync('bwrap') - if (usableBwrap === null) errors.push('bubblewrap (bwrap) not installed') + usableBwrap = onPath( + 'bwrap', + 'bwrapPath', + 'bubblewrap (bwrap) not installed', + ) } if (socatPath) { if (!isExecutable(socatPath)) errors.push(`socat not executable at ${socatPath}`) - } else if (whichSync('socat') === null) { - errors.push('socat not installed') + } else { + onPath('socat', 'socatPath', 'socat not installed') } if ( !seccompConfig?.argv0 && - getApplySeccompBinaryPath(seccompConfig?.applyPath) === null + getApplySeccompBinaryPath(seccompConfig?.applyPath, writable) === null ) { warnings.push('seccomp not available - unix socket access not restricted') } + // A directive is followed wherever it points; a writable one is reported. + for (const [option, file] of [ + ['bwrapPath', bwrapPath], + ['socatPath', socatPath], + ['seccomp.applyPath', seccompConfig?.applyPath], + ] as const) { + const warning = writableNamedHelperWarning(option, file, writable) + if (warning !== undefined) warnings.push(warning) + } + const uid0Error = uid0SandboxError({ euid: process.geteuid?.(), hasSetfcap: processHasBoundingCapability(CAP_SETFCAP), @@ -1453,11 +1546,14 @@ export function uid0SandboxError({ const uid0UserNamespaceProbes = new Map() /** - * Run `bwrap --unshare-user --dev-bind / / true` once and report whether this + * Run bwrap under `--unshare-user --dev-bind / /` once and report whether this * kernel actually refuses to map uid 0. `null` means it does not, so there is * nothing to report; otherwise bubblewrap's own first stderr line, or the * empty string when the probe could not be run at all and the prediction * stands unaided. + * + * The command run inside is bubblewrap's own `--version`, by its path: a bare + * name is looked up on PATH, and this runs as uid 0 with everything writable. */ function probeUid0UserNamespace(bwrap: string): string | null { const cached = uid0UserNamespaceProbes.get(bwrap) @@ -1465,7 +1561,7 @@ function probeUid0UserNamespace(bwrap: string): string | null { const probe = spawnSync( bwrap, - ['--unshare-user', '--dev-bind', '/', '/', 'true'], + ['--unshare-user', '--dev-bind', '/', '/', bwrap, '--version'], { timeout: 5000, stdio: ['ignore', 'ignore', 'pipe'], @@ -1514,8 +1610,10 @@ export async function initializeLinuxNetworkBridge( httpProxyPort: number, socksProxyPort: number, socatPath?: string, + writable: WritablePlaces = writablePlaces(undefined), ): Promise { - const socat = socatPath ?? 'socat' + // The bridges run on the host for as long as the sandbox is up. + const socat = socatPath ?? requireHostHelper('socat', 'socatPath', writable) const socketId = randomBytes(8).toString('hex') const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`) // Only allocated when ports differ; in the mux case the SOCKS side @@ -1663,6 +1761,61 @@ export async function initializeLinuxNetworkBridge( } } +/** Why a symbolic link that should be held in place is not; see README.md. */ +export type LinuxUnheldLinkReason = + | 'no_helper' + | 'told_no' + | 'not_told' + | 'older_helper' + | 'no_answer' + +/** + * Why the seccomp helper is not handed the links to hold, or undefined where + * it is. One built from older sources takes `--hold-link` for the command to + * run, so the words go to no helper that is not known to take them. + */ +function whyLinksAreNotHeld( + helperInUse: boolean, + config: SeccompConfig | undefined, + writable: WritablePlaces, +): LinuxUnheldLinkReason | undefined { + if (!helperInUse) return 'no_helper' + if (config?.holdsLinks !== undefined) { + return config.holdsLinks ? undefined : 'told_no' + } + // A path the caller gave need name the helper only inside the sandbox. + const helper = config?.argv0 + ? null + : getApplySeccompBinaryPath(config?.applyPath, writable) + if (helper === null || helper === config?.applyPath) return 'not_told' + // THREAT: read, never run. This is outside any sandbox, and where the + // library lies in a path the command may write (a checkout, a project's own + // node_modules) the file is whatever an earlier command left there. + try { + return fs.readFileSync(helper).includes('--holds-links') + ? undefined + : 'older_helper' + } catch { + return 'no_answer' + } +} + +/** A symbolic link where it lies, and why nothing holds it there. */ +export type LinuxUnheldLink = { path: string; reason: LinuxUnheldLinkReason } + +// By link: what the latest wrap that met it came to. +const unheldLinks = new Map() + +/** `SandboxManager.getLinuxUnheldLinks()`. */ +export function getLinuxUnheldLinks(): LinuxUnheldLink[] { + return [...unheldLinks].map(([path, reason]) => ({ path, reason })) +} + +/** For `reset()` and `updateConfig()`: the list is of one configuration. */ +export function forgetLinuxUnheldLinks(): void { + unheldLinks.clear() +} + /** * Read by the seccomp helper, never by this process. The write denies are * read-only binds, and a bind protects a path only in the mount namespace it @@ -1731,6 +1884,7 @@ let noUsernsLimitLogged = false function resolveApplySeccompPrefix( applyPath: string | undefined, argv0: string | undefined, + writable: WritablePlaces, ): string | undefined { if (argv0) { if (!applyPath) { @@ -1738,7 +1892,7 @@ function resolveApplySeccompPrefix( } return `ARGV0=${quote([argv0])} ${quote([applyPath])} ` } - const binary = getApplySeccompBinaryPath(applyPath) + const binary = getApplySeccompBinaryPath(applyPath, writable) return binary ? `${quote([binary])} ` : undefined } @@ -1795,14 +1949,13 @@ function buildSandboxCommand( socksSocketPath: string, userCommand: string, applySeccompPrefix: string | undefined, - shell?: string, - socatPath?: string, + shellPath: string, + socatPath: string, ): string { - // Default to bash for backward compatibility - const shellPath = shell || 'bash' - // Host filesystem is bind-mounted into the sandbox, so an explicit - // socatPath resolves to the same binary inside bwrap. - const socat = quote([socatPath ?? 'socat']) + // Host filesystem is bind-mounted into the sandbox, so socat's host path + // names the same binary inside bwrap. Never the bare name: these listeners + // start before the seccomp filter is applied. + const socat = quote([socatPath]) // socat's own backlog is 5. A connection that finds the queue full is // dropped, and tried again after 1 s, then 2 s, 4 s and so on: of a package // installer's burst, some wait for a minute. The kernel caps the number. @@ -2100,8 +2253,16 @@ async function generateFilesystemArgs( ripgrepConfig: RipgrepConfig = { command: 'rg' }, mandatoryDenySearchDepth: number = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH, allowGitConfig = false, - abortSignal?: AbortSignal, -): Promise<{ args: string[]; mandatoryDenyArgs: number }> { + abortSignal: AbortSignal | undefined, + writable: WritablePlaces, + /** Whether the caller has `linksToHold` held. */ + linksAreHeld: boolean, +): Promise<{ + args: string[] + mandatoryDenyArgs: number + /** The links the command could replace, each with what it said. */ + linksToHold: Array<[link: string, target: string]> +}> { const args: string[] = [] const startedIn = workingDirectory() // fs already imported @@ -2114,6 +2275,19 @@ async function generateFilesystemArgs( const denyWriteArgs: string[] = [] // Where the mandatory denies' binds begin in it: after the configuration's. let mandatoryDenyArgsStart = Infinity + // What the ways to the denied paths depend on; see followLinks. + const linksToDenies = new Map() + const leftOnTheWay = new Set() + /** + * Returns what stands in for `name` where the walk to it could not look in + * a directory: that directory, and the missing names it went up from before. + */ + const noteLinksTo = (name: string): string[] | undefined => { + const { links, left, passed, unsearched } = followLinks(name) + for (const [link, target] of links) linksToDenies.set(link, target) + for (const dir of left) leftOnTheWay.add(dir) + return unsearched === undefined ? undefined : [unsearched, ...passed] + } // Directories that a deny entry re-binds read-only inside the sandbox // (--ro-bind ), keyed by resolved dest, with every raw // (pre-resolution) spelling each was reached through. A non-existent deny @@ -2613,12 +2787,28 @@ async function generateFilesystemArgs( mandatoryDenySearchDepth, allowGitConfig, abortSignal, + writable, )), ] // INVARIANT: one plan, one working directory. A relative entry is resolved // against it on both sides of the await above, the only one here. if (workingDirectory() !== startedIn) throw new WorkingDirectoryChanged() + // Each deny path as both loops below take it. A directory the walk could + // not look in stands in for the path: bound read-only, it keeps its mode. + const rawDenyPaths = denyPaths.flatMap(pathPattern => { + const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) + // Both loops skip it. + if (rawPath.startsWith('/dev/')) return rawPath + const instead = noteLinksTo(rawPath) + if (instead === undefined) return rawPath + logForDebugging( + `[Sandbox Linux] Write deny path ${rawPath} cannot be looked at; denying ${instead[0]} whole instead`, + { level: 'warn' }, + ) + return instead + }) + // Duplicate deny entries must be collapsed: a duplicate // --ro-bind /dev/null hits a char device on the second pass and // bwrap's ensure_file() falls through to creat() on a read-only mount. @@ -2643,12 +2833,11 @@ async function generateFilesystemArgs( // where the loop masks that component and emits no bind for the // directory (the re-check below); an emitted one missing from the record // only costs a spurious abort. - for (const pathPattern of denyPaths) { - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) + for (const rawPath of rawDenyPaths) { if (rawPath.startsWith('/dev/')) { continue } - const resolvedPath = resolveSymlinkedDenyPath(rawPath, denyPaths) + const resolvedPath = resolveSymlinkedDenyPath(rawPath, rawDenyPaths) if (resolvedPath === null || resolvedPath.startsWith('/dev/')) { continue } @@ -2921,11 +3110,10 @@ async function generateFilesystemArgs( return '/dev/null' } } - for (const [index, pathPattern] of denyPaths.entries()) { + for (const [index, rawPath] of rawDenyPaths.entries()) { if (index === configuredDenyPaths.length) { mandatoryDenyArgsStart = denyWriteArgs.length } - const rawPath = normalizePathForSandbox(pathPattern, { literal: true }) // Skip /dev/* paths since --dev /dev already handles them if (rawPath.startsWith('/dev/')) { @@ -3299,9 +3487,39 @@ async function generateFilesystemArgs( // each: a symlink spelling would pin the chain of the link, not of where // the mount sits. Deny binds, tmpfs units and masks are emitted later and // land on top of both pins and covers. + // + // The read side's names count like the write denies': the next wrap finds + // what to hide by the name again. A path that cannot be looked at has its + // stand-in already (readDenyTargetOf), and one that is where it resolves to + // has no link on the way. + for (const name of [ + ...readDenyPlan().map(entry => entry.normalizedPath), + ...(maskedFileBinds ?? []).map(bind => bind.realPath), + ]) { + const { canonical, resolved } = canonicalLocationOf(name) + if (!resolved || canonical !== name) noteLinksTo(name) + } + readConfig?.matchedLinks?.forEach(noteLinksTo) + // A link is held where the command could replace it: in a directory it can + // write to, and sees as it is on the host. + const linksToHold = [...linksToDenies].filter( + ([link]) => + isWithinAnyAllowedWritePath(path.dirname(link)) && + !KERNEL_TOP_LEVEL_DIRS.some(dir => isAtOrUnder(link, dir)) && + !isHiddenByTmpfs(path.dirname(link), 'writes and reads'), + ) const pinArgs = ancestorPinArgs( [ ...denyWriteRawDests.keys(), + // The directories above a held link are pinned like those above a + // mount, and so is each the way goes up from, itself included. Where + // nothing holds the links, pins would keep nothing from being led away. + ...(linksAreHeld || linksToHold.length === 0 + ? [ + ...linksToHold.map(([link]) => link), + ...[...leftOnTheWay].map(dir => `${dir}/.`), + ] + : []), ...fileMasks.map(mask => mask.landing), ...readDenyTmpfsUnits.map(unit => unit.landing), ], @@ -3480,7 +3698,7 @@ async function generateFilesystemArgs( args.push('--ro-bind', emptySource, emptySource) } - return { args, mandatoryDenyArgs } + return { args, mandatoryDenyArgs, linksToHold } } /** @@ -3558,6 +3776,7 @@ export async function wrapCommandWithSandboxLinux( seccompConfig, bwrapPath, socatPath, + alsoWritable = [], observeSocketPath, abortSignal, } = params @@ -3612,6 +3831,13 @@ export async function wrapCommandWithSandboxLinux( let applySeccompPrefix: string | undefined try { + const writable = [ + ...writablePlaces(writeConfig ? writeRootsOf(writeConfig) : 'anywhere'), + ...alsoWritable, + ] + const bwrapBinary = + bwrapPath ?? requireHostHelper('bwrap', 'bwrapPath', writable) + // ========== SECCOMP FILTER (Unix Socket Blocking) ========== // apply-seccomp wraps the workload and applies the baked-in BPF filter // that blocks socket(AF_UNIX, ...). Skipped when allowAllUnixSockets is true. @@ -3619,6 +3845,7 @@ export async function wrapCommandWithSandboxLinux( applySeccompPrefix = resolveApplySeccompPrefix( seccompConfig?.applyPath, seccompConfig?.argv0, + writable, ) if (!applySeccompPrefix) { @@ -3779,7 +4006,16 @@ export async function wrapCommandWithSandboxLinux( } // ========== FILESYSTEM RESTRICTIONS ========== - const { args: fsArgs, mandatoryDenyArgs } = await generateFilesystemArgs( + const whyNotHeld = whyLinksAreNotHeld( + applySeccompPrefix !== undefined, + seccompConfig, + writable, + ) + const { + args: fsArgs, + mandatoryDenyArgs, + linksToHold, + } = await generateFilesystemArgs( readConfig, writeConfig, maskedFileBinds, @@ -3788,13 +4024,21 @@ export async function wrapCommandWithSandboxLinux( mandatoryDenySearchDepth, allowGitConfig, abortSignal, + writable, + whyNotHeld === undefined, ) + const holdWords = + whyNotHeld === undefined + ? quote(linksToHold.flatMap(pair => ['--hold-link', ...pair])) + : '' + if (holdWords !== '') applySeccompPrefix += `${holdWords} ` const mountsStart = bwrapArgs.length bwrapArgs.push(...fsArgs) const mounts = { start: mountsStart, end: bwrapArgs.length, mandatoryDenyArgs, + holdWordsBytes: Buffer.byteLength(holdWords, 'utf8'), } // Always bind /dev @@ -3852,10 +4096,9 @@ export async function wrapCommandWithSandboxLinux( applySeccompPrefix === undefined && allowNestedUserNamespaces !== true ) { - const bwrap = enableWeakerNestedSandbox - ? null - : (bwrapPath ?? whichSync('bwrap')) - if (bwrap !== null && bwrapCanDisableUserns(bwrap)) { + // Asked of the copy this wrap runs, never of PATH's: the probe runs it + // on the host. + if (!enableWeakerNestedSandbox && bwrapCanDisableUserns(bwrapBinary)) { bwrapArgs.push('--disable-userns') } else if (!noUsernsLimitLogged) { noUsernsLimitLogged = true @@ -3877,11 +4120,9 @@ export async function wrapCommandWithSandboxLinux( // ========== COMMAND ========== // Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work // Resolve the full path to the shell binary since bwrap doesn't use $PATH - const shellName = binShell || 'bash' - const shell = whichSync(shellName) - if (!shell) { - throw new Error(`Shell '${shellName}' not found in PATH`) - } + // THREAT: the shell starts the relays and the seccomp helper, so one the + // command could write would decide whether the filter is ever applied. + const shell = requireHostHelper(binShell || 'bash', 'binShell', writable) bwrapArgs.push('--', shell, '-c') // With network restrictions, route the command through buildSandboxCommand @@ -3894,7 +4135,7 @@ export async function wrapCommandWithSandboxLinux( command, applySeccompPrefix, shell, - socatPath, + socatPath ?? requireHostHelper('socat', 'socatPath', writable), ) bwrapArgs.push(sandboxCommand) } else if (applySeccompPrefix) { @@ -3904,11 +4145,7 @@ export async function wrapCommandWithSandboxLinux( bwrapArgs.push(command) } - const wrappedCommand = renderBwrapInvocation( - bwrapPath ?? 'bwrap', - bwrapArgs, - mounts, - ) + const wrappedCommand = renderBwrapInvocation(bwrapBinary, bwrapArgs, mounts) const restrictions = [] if (needsNetworkRestriction) restrictions.push('network') @@ -3921,6 +4158,18 @@ export async function wrapCommandWithSandboxLinux( `[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`, ) + for (const [link] of linksToHold) { + if (whyNotHeld === undefined) { + unheldLinks.delete(link) + continue + } + unheldLinks.set(link, whyNotHeld) + logForDebugging( + `[Sandbox Linux] A denied path leads through the symbolic link ${link}, which the command can replace: nothing holds it (${whyNotHeld})`, + { level: 'warn' }, + ) + } + // INVARIANT: a wrap that hands out a command is in the count when it does, // whatever a forced cleanup did meanwhile: its caller gives one back. if (forcedCleanups !== countedAfter) activeSandboxCount++ diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 1eb0db73f..5bda9cfc6 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -12,6 +12,7 @@ import { encodeSandboxedCommand, decodeSandboxedCommand, containsGlobChars, + followLinks, isAtOrUnder, isStrictlyUnder as isPathStrictlyUnder, nameLocation, @@ -362,14 +363,20 @@ interface ResolvedReadConfig { * links on the way resolved. A deny is resolved to its end. An allow is a * name, so its last component stays as written. Of a pattern, the directory * it starts from is what is resolved. The root is never a second spelling. + * `end` is the second spelling where the caller has walked there itself. */ -function inBothSpellings(kind: PathListKind, entry: PathEntry): PathEntry[] { +function inBothSpellings( + kind: PathListKind, + entry: PathEntry, + end?: string, +): PathEntry[] { const resolved = (p: string): string => pathSpellings(p)[1] ?? p const base = entryBaseDir(entry) const canonical = - kind === 'deny' || entry.glob + end ?? + (kind === 'deny' || entry.glob ? resolved(base) - : nameLocation(base, resolved) + : nameLocation(base, resolved)) // Nor among the devices: a rule on a name blanked with a link to /dev/null // would be one on /dev/null for every command. if ( @@ -998,12 +1005,20 @@ function generateWriteRules( // Combine user-specified and mandatory deny patterns (no ripgrep needed on // macOS). The caller's spellings are patterns when they read as patterns; // the mandatory entries carry their own literal/glob split. - const denyEntries = [ + const walked = [ ...(config.denyWithinAllow || []).map(toPathEntry), ...(config.literalDenyWithinAllow ?? []).map(toLiteralPathEntry), ...macGetMandatoryDenyEntries(allowGitConfig), - ] - denyEntries.push(...alsoReadAs(config.denyWithinAllow, 'deny')) + ...alsoReadAs(config.denyWithinAllow, 'deny'), + ].map(entry => ({ entry, ...followLinks(entryBaseDir(entry)) })) + // As written, and where the links on the way lead, there or not: that is the + // path Seatbelt compares a write through them with. Denied whole: a directory + // the walk could not look in, and a missing name the way goes up from. + const denyEntries = walked.flatMap(({ entry, end, unsearched, passed }) => [ + ...inBothSpellings('deny', entry, end), + ...(unsearched === undefined ? [] : [unsearched]).map(toLiteralPathEntry), + ...passed.map(toLiteralPathEntry), + ]) const { groups, rest: ungrouped } = groupLiteralDenyPaths(denyEntries) const groupFilters = groups.map(literalDenyGroupFilters) @@ -1029,6 +1044,14 @@ function generateWriteRules( moveFilters.add(`(literal ${escapePath(ancestorDir)})`) } } + // Each of those links stays where it lies, and each directory the way goes + // up from: whoever replaces one decides what the denied name leads to. + for (const stays of walked.flatMap(w => [...w.links.keys(), ...w.left])) { + if (isAtOrUnder(stays, '/dev')) continue + for (const kept of [stays, ...getAncestorDirectories(stays)]) { + moveFilters.add(`(literal ${escapePath(kept)})`) + } + } rules.push( ...renderRule( 'deny', @@ -1569,6 +1592,7 @@ export function wrapCommandWithSandboxMacOS( // Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work // Resolve the full path to the shell binary + // It runs under sandbox-exec, so the plain search of the whole PATH is right const shellName = binShell || 'bash' const shell = whichSync(shellName) if (!shell) { @@ -1588,8 +1612,10 @@ export function wrapCommandWithSandboxMacOS( // Use `env` command to set environment variables - each VAR=value is a separate // argument that quote() escapes properly, avoiding shell quoting issues + // env runs on the host, ahead of sandbox-exec, so it is named by its fixed + // path: a bare name would be whatever file PATH holds under it. const wrappedCommand = quote([ - 'env', + '/usr/bin/env', ...unsetEnvArgs, ...setEnvArgs, ...proxyEnvArgs, @@ -1641,7 +1667,8 @@ export function startMacOSSandboxLogMonitor( // Stream and filter kernel logs for all sandbox violations // We can't filter by specific logTag since it's dynamic per command - const logProcess = spawn('log', [ + // By its fixed path, not through PATH: the monitor runs on the host. + const logProcess = spawn('/usr/bin/log', [ 'stream', '--predicate', `(eventMessage ENDSWITH "${sessionSuffix}")`, diff --git a/src/sandbox/mxc-sandbox-utils.ts b/src/sandbox/mxc-sandbox-utils.ts index af9c0b14f..b1d44b816 100644 --- a/src/sandbox/mxc-sandbox-utils.ts +++ b/src/sandbox/mxc-sandbox-utils.ts @@ -52,7 +52,8 @@ * grant beneath a deny outranks it). So the lists go as they are. * - denyWrite: mapped into `deniedPaths` too, which over-denies * (BaseContainer has no write-only deny, so reads on those paths - * are lost as well). + * are lost as well). So the mandatory write denies are not sent: + * git does not run without reading `.git\config`. * - network: `egress.default: 'deny'`; with a proxy configured, * `ingress.default: 'allow'` + `ingress.hostLoopback: 'allow'` and * `runtimeConfig.networkProxy` naming the proxy port as the only diff --git a/src/sandbox/proxy-only.ts b/src/sandbox/proxy-only.ts index 7bcd6d488..2e64218dd 100644 --- a/src/sandbox/proxy-only.ts +++ b/src/sandbox/proxy-only.ts @@ -310,14 +310,15 @@ export function openFdStreams(fd: number): { input: Readable.fromWeb(file.stream()), output: new Writable({ write(chunk: Buffer, _encoding, callback) { - try { + // A failed write shows in one of three ways: write() rejects (a + // chunk too large to buffer), flush() throws, or flush() rejects. + // Inside an async function all three reach the callback. + const send = async (): Promise => { sink ??= file.writer() - sink.write(chunk) - // On a broken pipe flush() throws rather than rejects. - Promise.resolve(sink.flush()).then(() => callback(), callback) - } catch (err) { - callback(err as Error) + await sink.write(chunk) + await sink.flush() } + send().then(() => callback(), callback) }, final(callback) { Promise.resolve(sink?.end()).then(() => callback(), callback) diff --git a/src/sandbox/read-deny-glob.ts b/src/sandbox/read-deny-glob.ts index 51bce7f20..ab8c8b862 100644 --- a/src/sandbox/read-deny-glob.ts +++ b/src/sandbox/read-deny-glob.ts @@ -1,6 +1,6 @@ import { logForDebugging } from '../utils/debug.js' import { - type GlobWalkListings, + type GlobWalkOptions, isAtOrUnder, normalizePathForSandbox, pathSpellings, @@ -59,6 +59,11 @@ function collapseReadDenyLocations({ return kept } +type ReadDenyGlobOptions = Pick< + GlobWalkOptions, + 'anchor' | 'budget' | 'listings' +> & { matchedLinks?: Set } + /** * Expand a read-deny glob into the paths bwrap should mount over, collapsed * against `reExposedPaths` (the caller's allowRead and allowWrite entries). @@ -74,13 +79,16 @@ function collapseReadDenyLocations({ * pattern matches under an allowed path in there was never found, and would * come back unmasked. * @param opts.anchor - as in `ExpandGlobOptions.anchor`. + * @param opts.budget - as in `GlobWalkOptions.budget`. * @param opts.listings - one map for all the patterns of a configuration. + * @param opts.matchedLinks - receives the matches that are symbolic links, by + * name: what is returned for one is where it leads. */ export function expandReadDenyGlobLinux( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: ReadDenyGlobOptions = {}, ): string[] { return finish( expandReadDenyGlobLinuxSteps( @@ -97,13 +105,12 @@ export function* expandReadDenyGlobLinuxSteps( globPattern: string, reExposedPaths: readonly string[], unlistableDirs?: Set, - opts: { anchor?: string; listings?: GlobWalkListings } = {}, + opts: ReadDenyGlobOptions = {}, ): Steps { const walk = yield* walkGlobPatternSteps(globPattern, { withDirectoryForm: true, followSymlinkedDirectories: true, - anchor: opts.anchor, - listings: opts.listings, + ...opts, }) // Where a path the walk reported really lives: the denyRead loop mounts an // entry there, whatever spelling named it. @@ -141,6 +148,7 @@ export function* expandReadDenyGlobLinuxSteps( if (!namedBy.has(location)) namedBy.set(location, candidate) } for (const candidate of candidates) { + if (walk.symlinks.has(candidate)) opts.matchedLinks?.add(candidate) if (walk.symlinks.has(candidate) && !walk.realOf.has(candidate)) { if (!walk.uninspectableLinks.has(candidate)) { // A link that resolves to nothing denies nothing, and bwrap cannot diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index b8930db2b..99058b5a1 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -976,6 +976,18 @@ export const FilesystemConfigSchema = z.object({ 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), + denyReadGlobBudget: z + .object({ + maxEntries: z.number().int().positive().optional(), + timeoutMs: z.number().int().positive().optional(), + }) + .strict() + .optional() + .describe( + 'Linux: what expanding the globs of one read configuration may spend, all of them together, ' + + 'before the wrap is refused: directory entries read (default 20,000,000) and ' + + 'milliseconds (default 60,000).', + ), allowWrite: z .array(filesystemPathEntrySchema) .describe('Paths allowed for writing'), @@ -1004,7 +1016,13 @@ export const IgnoreViolationsConfigSchema = z * Ripgrep configuration schema */ export const RipgrepConfigSchema = z.object({ - command: z.string().describe('The ripgrep command to execute'), + command: z + .string() + .describe( + 'The ripgrep command to execute. A bare name (the default "rg") is ' + + 'looked up on PATH, passing over any copy inside an allowed write ' + + 'path; a path is run as given.', + ), args: z .array(z.string()) .optional() @@ -1081,6 +1099,7 @@ export const WindowsConfigSchema = z.object({ 'refuses a configuration the named backend cannot enforce; there ' + 'is no fallback between backends. Under mxc, commands start with ' + 'SandboxManager.spawnSandboxed() rather than wrapWithSandboxArgv(), ' + + 'the mandatory deny paths are not blocked, ' + 'and sandboxed programs can open windows on the desktop (UI is on ' + 'so that ordinary tools start; clipboard and input injection stay ' + 'blocked).', @@ -1147,6 +1166,15 @@ export const SeccompConfigSchema = z.object({ 'applyPath resolves inside the bwrap namespace and that the target ' + 'binary implements the apply-seccomp interface when ARGV0 matches.', ), + holdsLinks: z + .boolean() + .optional() + .describe( + 'Whether the helper takes --hold-link, which keeps the symbolic links ' + + 'on the way to denied paths in place. Taken at its word. Not set, a ' + + 'helper the library found for itself is read for the option, and ' + + 'one named by applyPath is handed no link. false turns holding off.', + ), }) /** @@ -1242,8 +1270,8 @@ export const SandboxRuntimeConfigSchema = z .max(10) .optional() .describe( - 'How deep below the working directory dangerous names are looked for on Linux (default: 3): ' + - 'a dangerous file down to this depth, and a dangerous directory, or the hooks and ' + + 'How deep below the working directory dangerous names are looked for on Linux and Windows (default: 3): ' + + 'a dangerous file down to this depth, and on Linux a dangerous directory, or the hooks and ' + 'config of a repository, one level higher up. ' + 'Higher values provide more protection but slower performance.', ), @@ -1258,13 +1286,15 @@ export const SandboxRuntimeConfigSchema = z .optional() .describe( 'Linux only: absolute path to the bwrap (bubblewrap) binary. ' + - 'When set, this path is used directly instead of resolving "bwrap" via PATH.', + 'When set, this path is used directly instead of resolving "bwrap" via PATH ' + + '(where a copy inside an allowed write path is passed over).', ), socatPath: binaryPathSchema .optional() .describe( 'Linux only: absolute path to the socat binary. ' + - 'When set, this path is used directly instead of resolving "socat" via PATH.', + 'When set, this path is used directly instead of resolving "socat" via PATH ' + + '(where a copy inside an allowed write path is passed over).', ), javaAgentJarPath: binaryPathSchema .optional() diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 9738c78dd..324fd20ef 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -37,7 +37,6 @@ import { type MitmCA, } from './mitm-ca.js' import { logForDebugging } from '../utils/debug.js' -import { whichSync } from '../utils/which.js' import type { RipgrepConfig } from '../utils/ripgrep.js' import { getPlatform, getWslVersion } from '../utils/platform.js' import * as fs from 'fs' @@ -48,6 +47,7 @@ import type { SandboxRuntimeConfig, SeccompConfig, } from './sandbox-config.js' +import { FilesystemConfigSchema } from './sandbox-config.js' import type { SandboxAskCallback, CredentialRestrictionConfig, @@ -63,9 +63,20 @@ import { checkLinuxDependencies, type SandboxDependencyCheck, cleanupBwrapMountPoints, + forgetLinuxUnheldLinks, + getLinuxUnheldLinks, + type LinuxUnheldLink, linuxGetCwdMandatoryDenyPaths, + LinuxSandboxProfileError, } from './linux-sandbox-utils.js' import { expandReadDenyGlobLinuxSteps } from './read-deny-glob.js' +import { + describeUnavailableHostHelper, + findHostHelper, + writableNamedHelperWarning, + writablePlaces, + type WritablePlaces, +} from './host-helpers.js' import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, @@ -88,6 +99,7 @@ import { windowsSrtWinOutdatedError, missingWindowsSrtWinCapability, windowsGrantsBeneathDenies, + windowsMandatoryDenyPaths, type WindowsProfileGrantItem, type WindowsProfileGrantResult, type WindowsProfileGrantStatus, @@ -125,11 +137,15 @@ import { containsGlobChars, globBaseDirIsRoot, globPatternBaseDir, + isUncPath, normalizePathForSandbox, removeTrailingGlobSuffix, + GlobWalkBudgetError, walkGlobPatternSteps, workingDirectory, + type GlobWalkBudget, type GlobWalkListings, + newGlobWalkBudget, type Steps, finish, finishInTurns, @@ -223,6 +239,9 @@ let proxyAuthToken: string | undefined // at initialize(). `undefined` means no stamp/grant was applied // (gates running `acl restore`/`acl revoke` at reset()). let windowsFsStampedSet: WindowsFsAccessSet | undefined +// The objects this session has put a write deny on, each by its path and +// file index: see `windowsMandatoryDeniesDue`. +let windowsWriteDenied = new Map() // The sandbox user SID captured at initialize(). reset() uses this // so a config change between init and reset can't strand ACEs // under a different SID. @@ -1694,6 +1713,12 @@ async function initializeSteps( // catch's best-effort revoke/restore can address whatever // partially landed. windowsFsSbUserSid = sb + // A share is not looked at (see isUncPath). No mandatory deny is on one. + const writeDenied = new Map( + acc.denyWrite + .filter(p => !isUncPath(p)) + .map(p => [p, windowsFileIndexOf(p)]), + ) // One call, always made: it also records this process as a // session. Write roots are kept at reset(); a failure throws. await grantWindowsAclPersistent({ @@ -1710,6 +1735,7 @@ async function initializeSteps( // recorded with srt-win, whatever it was given. Set before the // probe, so that a reset() during it releases what was granted. windowsFsStampedSet = acc + windowsWriteDenied = writeDenied logForDebugging( `[Sandbox Windows] fs applied: ` + `${acc.grantWrite.length} grantWrite, ` + @@ -1793,8 +1819,10 @@ async function initializeSteps( // both. Resolved once here, advertised via JAVA_TOOL_OPTIONS per command. // Async: the global-npm fallback spawns `npm root -g`. javaAgentJarPath = - (await getJavaProxyAgentJarPathAsync(config.javaAgentJarPath)) ?? - undefined + (await getJavaProxyAgentJarPathAsync( + config.javaAgentJarPath, + hostWritablePlaces(), + )) ?? undefined stopIfReset() // Leaves are minted lazily per-CONNECT (after this point), so setting // the CDP URL now means every leaf carries it. See MitmCA.crlUrl. @@ -1822,6 +1850,7 @@ async function initializeSteps( httpProxyPort, socksProxyPort, config.socatPath, + hostWritablePlaces(), ) stopIfReset() } @@ -1866,6 +1895,21 @@ function isSandboxingEnabled(): boolean { return config !== undefined } +/** + * Where a wrap under the installed configuration lets the sandboxed command + * write (see `writablePlaces`), for what is looked up outside a wrap. A + * configuration that cannot be read counts as none: whoever enforces it says + * what is wrong with it. POSIX only, like the search. + */ +function hostWritablePlaces(): WritablePlaces { + if (getPlatform() === 'windows') return writablePlaces('anywhere') + try { + return writablePlaces(config && writeRootsOf(getFsWriteConfig())) + } catch { + return writablePlaces(undefined) + } +} + /** * Platform-independent part of the dependency check. Returns either * a finished result (POSIX, unsupported platform, or a Windows @@ -1896,14 +1940,31 @@ function checkDependenciesCommon(ripgrepConfig?: RipgrepConfig): // expand glob deny-patterns to concrete paths for bwrap. macOS seatbelt // profiles take regex patterns directly, so rg is never invoked there. const rgToCheck = ripgrepConfig ?? config?.ripgrep ?? { command: 'rg' } - if (whichSync(rgToCheck.command) === null) { - errors.push(`ripgrep (${rgToCheck.command}) not found`) + const writable = hostWritablePlaces() + // Looked for the way the wrap will look for it. + const search = findHostHelper(rgToCheck.command, writable) + if (search.path === null) { + errors.push( + describeUnavailableHostHelper( + rgToCheck.command, + 'ripgrep.command', + search, + `ripgrep (${rgToCheck.command}) not found`, + ), + ) } + const warning = writableNamedHelperWarning( + 'ripgrep.command', + rgToCheck.command, + writable, + ) + if (warning !== undefined) warnings.push(warning) const linuxDeps = checkLinuxDependencies({ seccompConfig: config?.seccomp, bwrapPath: config?.bwrapPath, socatPath: config?.socatPath, + writable, }) errors.push(...linuxDeps.errors) warnings.push(...linuxDeps.warnings) @@ -2003,7 +2064,10 @@ async function checkDependenciesAsync( // (`npm root -g`) runs off the event loop; the sync check below then // hits the shared path cache. if (getPlatform() === 'linux' && !config?.seccomp?.argv0) { - await getApplySeccompBinaryPathAsync(config?.seccomp?.applyPath) + await getApplySeccompBinaryPathAsync( + config?.seccomp?.applyPath, + hostWritablePlaces(), + ) } const common = checkDependenciesCommon(ripgrepConfig) if ('done' in common) return common.done @@ -2197,11 +2261,8 @@ function* resolveReadPathEntries( !literal.has(p) && containsGlobChars(stripped) ) { - for (const found of yield* withOtherReadings( - p, - stripped, - kind, - expandGlob, + for (const found of yield* withinBudget( + withOtherReadings(p, stripped, kind, expandGlob), )) { resolved.add(found) } @@ -2238,11 +2299,49 @@ function stripWriteGlobs( }) } +/** + * What the glob walks of one read configuration may spend between them, on + * Linux, its clock starting now: `filesystem.denyReadGlobBudget`, a wrap's own + * limits over the session's one by one. Checked here, because only the CLI + * runs the schema and a limit that is no number would never be reached. + */ +function readGlobBudget( + ...limits: Array +): GlobWalkBudget { + const [session, own] = limits.map(given => + FilesystemConfigSchema.shape.denyReadGlobBudget.parse(given, { + path: ['filesystem', 'denyReadGlobBudget'], + }), + ) + return newGlobWalkBudget({ + maxEntries: own?.maxEntries ?? session?.maxEntries, + timeoutMs: own?.timeoutMs ?? session?.timeoutMs, + }) +} + +/** `walk`, with a budget that runs out in it told as + * {@link LinuxSandboxProfileError} `deny_glob_too_large`. */ +function* withinBudget(walk: Steps): Steps { + try { + return yield* walk + } catch (error) { + if (!(error instanceof GlobWalkBudgetError)) throw error + throw new LinuxSandboxProfileError( + 'deny_glob_too_large', + `${error.message}; the allowRead, denyRead and credentials.files patterns of one configuration share the budget: ` + + `narrow the pattern, remove or move what it walks into, or raise filesystem.denyReadGlobBudget`, + error, + ) + } +} + function* expandAllowReadGlob( + budget: GlobWalkBudget, pattern: string, anchor?: string, ): Steps { - const expanded = (yield* walkGlobPatternSteps(pattern, { anchor })).matches + const expanded = (yield* walkGlobPatternSteps(pattern, { anchor, budget })) + .matches logForDebugging( `[Sandbox] Expanded allowRead glob pattern "${pattern}" to ${expanded.length} paths on Linux`, ) @@ -2270,11 +2369,12 @@ function getFsReadConfig(): FsReadRestrictionConfig { ) // allowRead (re-allow within denied regions) is resolved first: the // denyRead glob expansion collapses against it. + const budget = readGlobBudget(config.filesystem.denyReadGlobBudget) const allowPaths = finish( resolveReadPathEntries( 'allow', config.filesystem.allowRead ?? [], - expandAllowReadGlob, + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) const reExposedPaths = reExposedBy( @@ -2283,6 +2383,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { getFsWriteConfig(), ) const unlistableDenyDirs = new Set() + const matchedLinks = new Set() const listings: GlobWalkListings = new Map() const denyPaths = finish( resolveReadDenies( @@ -2293,7 +2394,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, budget, listings, matchedLinks }, ), ), ) @@ -2302,6 +2403,7 @@ function getFsReadConfig(): FsReadRestrictionConfig { ...denyPaths, allowWithinDeny: allowPaths, unlistableDenyDirs: [...unlistableDenyDirs], + ...(matchedLinks.size > 0 && { matchedLinks: [...matchedLinks] }), ...literalReadLists( config.filesystem.denyRead, config.filesystem.allowRead, @@ -2378,15 +2480,26 @@ function computeWindowsFsAccessSet( } } const expand = expandWindowsFsPaths + const allowWrite = expand(fs?.allowWrite ?? [], { mode: 'grant' }) // `mode: 'deny'` — non-existent literals reach srt-win, which // creates a placeholder chain and stamps it (deny lands on the // exact target path). `mode: 'grant'` drops them (a grant on // nothing is meaningless). return rankWindowsFsAccess({ allowRead: expand(fs?.allowRead ?? [], { mode: 'grant' }), - allowWrite: expand(fs?.allowWrite ?? [], { mode: 'grant' }), + allowWrite, denyRead: expand(fs?.denyRead ?? [], { mode: 'deny' }), - denyWrite: expand(fs?.denyWrite ?? [], { mode: 'deny' }), + denyWrite: [ + ...expand(fs?.denyWrite ?? [], { mode: 'deny' }), + // Not for mxc, which is handed a write deny as a full one: git could + // not read `.git\config`. + ...(c.windows?.backend === 'mxc' + ? [] + : windowsMandatoryDenyPaths(process.cwd(), allowWrite, { + maxDepth: c.mandatoryDenySearchDepth ?? 3, + allowGitConfig: fs?.allowGitConfig ?? false, + })), + ], credentialDeny: [ ...expand(getCredentialDenyReadPaths(c.credentials), { mode: 'deny' }), ...maskedFiles, @@ -2394,6 +2507,41 @@ function computeWindowsFsAccessSet( }) } +/** Which object is at `p`: undefined where that cannot be told. */ +function windowsFileIndexOf(p: string): bigint | undefined { + try { + return fs.lstatSync(p, { bigint: true }).ino + } catch { + return undefined + } +} + +/** + * The mandatory write denies for a command run in `cwd` that are not on disk + * yet: a name that is new since `initialize()`, and one that is another object + * than the one denied. A deny on a file goes with the object, and git and most + * editors write a file by putting a new one in its place. + */ +function windowsMandatoryDeniesDue( + cwd: string, +): Map { + const due = new Map() + for (const p of windowsMandatoryDenyPaths( + cwd, + windowsFsStampedSet?.grantWrite ?? [], + { + maxDepth: getMandatoryDenySearchDepth(), + allowGitConfig: getAllowGitConfig(), + }, + )) { + const index = windowsFileIndexOf(p) + if (!windowsWriteDenied.has(p) || windowsWriteDenied.get(p) !== index) { + due.set(p, index) + } + } + return due +} + /** * The `mode: "mask"` files, as credential denies: an ACL cannot redirect a * read, so on Windows, as on macOS, a masked file is unreadable. Each is a @@ -2418,7 +2566,9 @@ function windowsMaskedFileDenies( function rawWindowsFsInputs(c: SandboxRuntimeConfig) { // Keyed exactly on what {@link computeWindowsFsAccessSet} reads of // the config. The masked files are not session-wide: each wrap - // denies the ones the session has not stamped. + // denies the ones the session has not stamped. So with the mandatory + // denies, whose two settings each wrap reads anew. `windows.backend` is + // the session's from initialize() on. // `network.allowedDomains` does NOT feed file-deny (only mask // injectHosts), so a network-only updateConfig hits the cache. return { @@ -2847,13 +2997,17 @@ async function wrapWithSandboxOnce( // allowRead is resolved first: on Linux a denyRead glob's expansion is // collapsed against the paths that re-expose contents under a denied // directory (allowRead + allowWrite), so both must be final here. + const budget = readGlobBudget( + config?.filesystem.denyReadGlobBudget, + customConfig?.filesystem?.denyReadGlobBudget, + ) const expandedAllowRead = await walked( resolveReadPathEntries( 'allow', customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [], - expandAllowReadGlob, + (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor), ), ) if (expandedAllowRead === REPLACED) return startOver() @@ -2875,6 +3029,7 @@ async function wrapWithSandboxOnce( writeConfig, ) const unlistableDenyDirs = new Set() + const matchedLinks = new Set() const listings: GlobWalkListings = new Map() const expandedDenyRead = await walked( resolveReadDenies( @@ -2885,7 +3040,7 @@ async function wrapWithSandboxOnce( pattern, reExposedPaths, unlistableDenyDirs, - { anchor, listings }, + { anchor, budget, listings, matchedLinks }, ), ), ) @@ -2898,6 +3053,7 @@ async function wrapWithSandboxOnce( allowWithinDeny: expandedAllowRead, ...(ownAllowWithinDeny.length > 0 && { ownAllowWithinDeny }), unlistableDenyDirs: [...unlistableDenyDirs], + matchedLinks: [...matchedLinks], ...literalReadLists( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead, customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead, @@ -3034,6 +3190,7 @@ async function wrapWithSandboxOnce( seccompConfig: getSeccompConfig(), bwrapPath: config?.bwrapPath, socatPath: config?.socatPath, + alsoWritable: hostWritablePlaces(), observeSocketPath: linuxMonitor?.observeSocketPath, abortSignal, }).catch((err: unknown) => { @@ -3183,6 +3340,29 @@ async function wrapWithSandboxArgv( p => !sessRead.has(p) && !sessWrite.has(p), ) } + // Kept with the write root like the session's own, so each is written + // once and no command after this one pays for it. + const due = windowsMandatoryDeniesDue(cwd ?? process.cwd()) + if (!fsCfg?.disabled && due.size > 0 && windowsFsSbUserSid !== undefined) { + await grantWindowsAclPersistent({ + sandboxUserSid: windowsFsSbUserSid, + read: [], + write: [], + denyRead: [], + // With the session's write grants beneath them, which they outrank. + denyWrite: rankWindowsFsAccess({ + allowRead: [], + allowWrite: [], + denyRead: [], + denyWrite: [...due.keys()], + credentialDeny: [], + held: windowsFsStampedSet, + }).denyWrite, + srtWin: srtWinSpawn, + }) + // As looked at before the write: one replaced since is due again. + for (const [p, index] of due) windowsWriteDenied.set(p, index) + } // Per-exec deny rides on argv (`acl stamp` reads stdin, but // exec's stdin belongs to the child). The CreateProcessW // length check lives in `wrapCommandWithSandboxWindows` @@ -3518,6 +3698,7 @@ function updateConfig(newConfig: SandboxRuntimeConfig): void { config = structuredClone({ ...newConfig, network: rest }) config.network.filterRequest = filterRequest configInstalls++ + forgetLinuxUnheldLinks() resolvedAddressGuard = nextGuard // Re-resolve parent proxy so hot-reload picks up changes. Note: the proxy // servers capture `parentProxy` by value at creation, so changes here take @@ -3685,6 +3866,7 @@ async function reset(): Promise { // Clean up any leftover bwrap mount points. Force past the // active-sandbox counter — reset() means the session is over. cleanupBwrapMountPoints({ force: true }) + forgetLinuxUnheldLinks() // Stop log monitor if (logMonitorShutdown) { @@ -3916,6 +4098,8 @@ export interface ISandboxManager { }, options?: CheckDependenciesOptions, ): Promise + /** On Linux it expands the `denyRead` globs, and throws + * {@link LinuxSandboxProfileError} `deny_glob_too_large` as the wrap does. */ getFsReadConfig(): FsReadRestrictionConfig getFsWriteConfig(): FsWriteRestrictionConfig getNetworkRestrictionConfig(): NetworkRestrictionConfig @@ -4013,6 +4197,12 @@ export interface ISandboxManager { getSandboxViolationStore(): SandboxViolationStore annotateStderrWithSandboxFailures(command: string, stderr: string): string getLinuxGlobPatternWarnings(): string[] + /** + * Linux: the symbolic links on the way to a denied path that a command + * could replace, each with the reason nothing holds it. Each is listed by + * the latest wrap that met it; `reset()` and `updateConfig()` empty the list. + */ + getLinuxUnheldLinks(): LinuxUnheldLink[] getConfig(): SandboxRuntimeConfig | undefined getMitmCA(): MitmCA | undefined /** @@ -4083,6 +4273,7 @@ export const SandboxManager: ISandboxManager = { getSandboxViolationStore, annotateStderrWithSandboxFailures, getLinuxGlobPatternWarnings, + getLinuxUnheldLinks, getConfig, updateConfig, } as const diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index e282b6025..52340805c 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -45,6 +45,11 @@ export interface FsReadRestrictionConfig { * unmasked. Linux only: the other backends match globs natively. */ unlistableDenyDirs?: string[] + /** + * The names a glob expansion matched that are symbolic links: `denyOnly` + * holds what each leads to. Linux only, likewise. + */ + matchedLinks?: string[] } /** diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index c85cbbdb9..2cb4e35b5 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,7 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' -import { realPathOf } from '../utils/real-path.js' +import { realPathOf, wayPastMissingNames } from '../utils/real-path.js' import { denyGlobRegex, globToRegex, @@ -116,6 +116,63 @@ export function nameLocation( return `${parent === '/' ? '' : parent}/${path.basename(p)}` } +/** + * What has to stay as it is for `p`, a denied name, to go on leading where it + * leads, and where that is. `p` is folded in text first, as by `realPathOf`. + * + * INVARIANT: "could not look" is never "no link". A walk that stops for any + * reason but a name that is not there, or that nothing can be at, has no end, + * and `unsearched` is the directory it could not look in: a same-uid command + * can take a directory's search bit away and give it back from inside the next + * sandbox. Never the root, which stands in for nothing. + */ +export function followLinks(p: string): { + /** The symbolic links on the way: where each lies, and what it says, '' + * where that is no text. */ + links: Map + /** The directories a link's target goes into and, by `..`, out of again. */ + left: Set + /** The names that are not there which one goes up from in the same way. */ + passed: string[] + /** Where the way ends, what is not there taken as written: the path a write + * through a dangling link would create. A way that goes up again further on + * among such names ends at the first. None where the walk stopped. */ + end?: string + unsearched?: string +} { + const links = new Map() + const left = new Set() + const walk = (): ReturnType => { + const { real, rest, shut, passed } = wayPastMissingNames(path.resolve(p), { + link: (at, target) => links.set(at, target), + left: dir => left.add(dir), + }) + if (shut) return { links, left, passed, unsearched: real || undefined } + const names = rest.includes('..') ? rest.slice(0, 1) : rest + return { links, left, passed, end: [real, ...names].join('/') || '/' } + } + try { + return walk() + } catch { + // Once more: a look can fail in passing (EIO, ESTALE), and what would + // follow from that is a directory denied whole. + } + try { + return walk() + } catch (err) { + const { code, path: at } = err as NodeJS.ErrnoException + // A link all the same, which says what no string spells. + if (code === 'EILSEQ' && at) links.set(at, '') + const nothingCanBeThere = + isAbsenceErrno(err) || + code === 'EILSEQ' || + code === 'ERR_INVALID_ARG_VALUE' + const dir = path.dirname(at ?? path.resolve(p)) + const unsearched = nothingCanBeThere || dir === '/' ? undefined : dir + return { links, left, passed: [], unsearched } + } +} + /** `process.cwd()`, or undefined where that throws: it has been removed. */ export function workingDirectory(): string | undefined { try { @@ -1252,6 +1309,67 @@ export interface ExpandGlobOptions { anchor?: string } +/** + * What the walks handed it may spend between them: one count and one clock, + * so the patterns of one configuration cannot each spend a full one. + */ +export interface GlobWalkBudget { + /** The directory entries the walks may read, together. */ + readonly maxEntries: number + /** How long they may take, together, in milliseconds. */ + readonly timeoutMs: number + /** When the budget was made, as a `performance.now()` reading. */ + readonly startedAt: number + /** The directory entries read so far. */ + entries: number +} + +/** A budget nothing has been spent from, its clock starting now. */ +export function newGlobWalkBudget( + limits: { maxEntries?: number; timeoutMs?: number } = {}, +): GlobWalkBudget { + return { + maxEntries: limits.maxEntries ?? 20_000_000, + timeoutMs: limits.timeoutMs ?? 60_000, + startedAt: performance.now(), + entries: 0, + } +} + +/** + * Thrown by {@link walkGlobPattern} when its budget runs out. What it had + * found is not handed back: a deny list cut short is a path left readable. + */ +export class GlobWalkBudgetError extends Error { + /** Which limit was reached. */ + readonly exhausted: 'entries' | 'time' + /** The entries read under the budget, by this walk and those before. */ + readonly entries: number + /** Milliseconds since the budget was made. */ + readonly elapsedMs: number + readonly maxEntries: number + readonly timeoutMs: number + constructor( + /** The pattern being walked, as the caller wrote it. */ + readonly pattern: string, + /** The directory the walk was listing. */ + readonly directory: string, + budget: GlobWalkBudget, + ) { + const exhausted = budget.entries > budget.maxEntries ? 'entries' : 'time' + const elapsedMs = Math.round(performance.now() - budget.startedAt) + super( + `Pattern "${pattern}" could not be expanded: the ${exhausted} ran out with ${budget.entries} of ${budget.maxEntries} directory entries read after ${elapsedMs} of ${budget.timeoutMs} ms, while listing ${directory}`, + ) + this.name = 'GlobWalkBudgetError' + this.exhausted = exhausted + this.entries = budget.entries + this.elapsedMs = elapsedMs + this.maxEntries = budget.maxEntries + this.timeoutMs = budget.timeoutMs + } +} + /** What the walk reads of a listed entry. */ type GlobWalkEntry = Pick @@ -1263,6 +1381,7 @@ export type GlobWalkListings = Map export type GlobWalkOptions = ExpandGlobOptions & { withDirectoryForm?: boolean followSymlinkedDirectories?: boolean + budget?: GlobWalkBudget /** Handed to every walk of one configuration, so that patterns with a * base in common list each directory once between them. */ listings?: GlobWalkListings @@ -1610,6 +1729,9 @@ export function toForwardSlashes(s: string): string { * `globPath` without its trailing `/**`, with the symlinks seen recorded. * With `followSymlinkedDirectories` it also lists through a symlinked * directory and reports every match where it really lives. + * + * With a `budget` it throws {@link GlobWalkBudgetError} once that is spent. + * One step is not cut short: a filesystem call that blocks, or a slow match. */ export function walkGlobPattern( globPath: string, @@ -1719,6 +1841,22 @@ export function* walkGlobPatternSteps( const records = new Map() const listings: GlobWalkListings = opts.listings ?? new Map() const pending: Frame[] = [] + /** Counts `read` more entries of `dir` read from disk, and throws once the + * budget is spent. An entry another pattern has read already costs time + * alone, so the tree that fits does not shrink with every pattern written + * over it. The clock is read every time: what one entry costs is not + * bounded, so a stride would not bound the time. */ + const spend = (dir: string, read: 0 | 1): void => { + const budget = opts.budget + if (budget === undefined) return + budget.entries += read + if ( + budget.entries > budget.maxEntries || + performance.now() - budget.startedAt >= budget.timeoutMs + ) { + throw new GlobWalkBudgetError(globPath, dir, budget) + } + } const codeOf = (err: unknown): string | undefined => (err as NodeJS.ErrnoException | undefined)?.code /** @@ -1746,7 +1884,7 @@ export function* walkGlobPatternSteps( try { return call(real) } catch (first) { - if (short === real) throw first + if (short === real || first instanceof GlobWalkBudgetError) throw first let answer: T try { answer = call(short) @@ -1819,6 +1957,7 @@ export function* walkGlobPatternSteps( ) { return entry } + spend(real, 0) const { name } = entry const spelled = path.join(listedAs, name) let type: 'directory' | 'link' | 'other' = 'other' @@ -1886,6 +2025,8 @@ export function* walkGlobPatternSteps( const fresh = frame.positions.filter(p => !record.listedFor.has(p)) if (fresh.length === 0) continue yield + // After the step, in which time can have passed, and before the listing. + spend(dir, 0) // Once. What one pattern denies is denied for the whole configuration, so // what the short name listed is kept for the next pattern like any other. const denyWhole = (): void => { @@ -1895,8 +2036,8 @@ export function* walkGlobPatternSteps( if (real !== dir) walk.realOf.set(dir, real) } let entries: GlobWalkEntry[] + const known = listings.get(real) try { - const known = listings.get(real) if (known instanceof Error) throw known entries = known ?? @@ -1912,6 +2053,8 @@ export function* walkGlobPatternSteps( ) listings.set(real, entries) } catch (err) { + // Not a directory that could not be listed. + if (err instanceof GlobWalkBudgetError) throw err const errorCode = codeOf(err) logForDebugging( `[Sandbox] Error listing ${dir} for glob pattern ${globPath}: ${err}`, @@ -1935,6 +2078,7 @@ export function* walkGlobPatternSteps( } for (const p of fresh) record.listedFor.add(p) for (const entry of entries) { + spend(dir, known === undefined ? 1 : 0) // Nearly every entry of a large tree: a plain file the pattern does not // match, which nothing below records. A pattern that splits is matched // by name, so no path need be spelled to find that out. diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index ab61ec83a..8df20454a 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -13,7 +13,9 @@ import { buildGitConfigEnv, normalizePathForSandbox, containsGlobCharsWin, + DANGEROUS_FILES, expandGlobPattern, + getDangerousDirectories, isAtOrUnder, isUncPath, markedLiteralPath, @@ -2782,6 +2784,127 @@ export function coveringWindowsPath( } } +/** + * The mandatory write denies for a command run in `cwd`, from the definitions + * the Linux and macOS backends read: the dangerous files and directories, + * `.git\hooks` and (unless `allowGitConfig`) `.git\config`, each name down to + * `maxDepth` levels below `cwd` (`a\b\.bashrc`, `a\b\.git` at 3), + * `node_modules` apart. Of those, the ones a command could write: at or + * beneath one of `writeRoots`. + * + * Only what IS THERE is returned: `srt-win` answers a missing deny target with + * a placeholder that stays, which would leave an empty `.mcp.json` or + * `.vscode\` in every directory ever sandboxed. + * + * Where `.git` is a FILE (a linked worktree, a submodule) the file is denied: + * rewritten, it would send the user's next git command to a directory with + * hooks of the writer's own. So are the same names where it leads + * ({@link windowsGitFileDirs}). + * + * THREAT: a link or junction is passed over, whatever its name. A sandboxed + * command can make one where nothing was, `srt-win` would put the deny on + * what it leads to (`.vscode` -> `.` closes the root), and to a share it would + * have this process, which no fence holds, call a server of the maker's + * choosing. + */ +export function windowsMandatoryDenyPaths( + cwd: string, + writeRoots: readonly string[], + opts: { maxDepth: number; allowGitConfig: boolean }, +): string[] { + if (writeRoots.length === 0) return [] + const writeRootOf = coveringWindowsPath(writeRoots) + // Denied wherever they are met, and denied inside a directory of that name. + const whole = new Set(DANGEROUS_FILES) + const inside = new Map([ + ['.git', new Set(opts.allowGitConfig ? ['hooks'] : ['hooks', 'config'])], + ]) + for (const dir of getDangerousDirectories()) { + const [name, part] = dir.split('/') as [string, string?] + if (part === undefined) whole.add(name) + else inside.set(name, (inside.get(name) ?? new Set()).add(part)) + } + const found: string[] = [] + const walk = (dir: string, depth: number, only?: Set): void => { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const name = normalizeCaseForComparison(entry.name) + const full = path.join(dir, entry.name) + if ((only ?? whole).has(name)) { + found.push(full) + } else if (only !== undefined) { + continue + } else if (name === '.git' && entry.isFile()) { + found.push(full) + for (const led of windowsGitFileDirs(full, writeRootOf)) { + walk(led, depth, inside.get(name)) + } + } else if (entry.isDirectory() && name !== 'node_modules') { + const parts = inside.get(name) + if (parts !== undefined) walk(full, depth, parts) + else if (depth < opts.maxDepth) walk(full, depth + 1) + } + } + } + const top = resolveWindowsPath(cwd) + if (top.state === 'resolved') walk(top.path, 1) + // Sorted: the order a directory is listed in differs from disk to disk. + return found.filter(p => writeRootOf(p) !== undefined).sort() +} + +/** + * The directories a `.git` file leads to: the one its `gitdir:` line names + * (relative to the file's directory) and, for a linked worktree, the one that + * directory's `commondir` names (relative to it). + * + * THREAT: a sandboxed command may have written both files. So nothing is read + * at a path it could not write anyway, and only a small regular file is read + * at all. A UNC or device form is dropped before it is so much as resolved, + * and so is a path with a link on the way, looked for from the top down: see + * {@link windowsMandatoryDenyPaths}. + */ +function windowsGitFileDirs( + gitFile: string, + writeRootOf: (p: string) => string | undefined, +): string[] { + const firstLine = (file: string): string => { + try { + const st = fs.lstatSync(file) + if (!st.isFile() || st.size > 4096) return '' + return fs.readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]!.trim() + } catch { + return '' + } + } + const follow = (from: string, named = ''): string | undefined => { + if (named === '' || isUnprobedWindowsPath(named)) return undefined + const dir = path.resolve(from, named) + const names = dir.split(path.sep) + try { + for (let n = 2; n <= names.length; n++) { + const above = names.slice(0, n).join(path.sep) + if (fs.lstatSync(above).isSymbolicLink()) return undefined + } + } catch { + return undefined + } + return writeRootOf(dir) === undefined ? undefined : dir + } + const gitDir = follow( + path.dirname(gitFile), + /^gitdir:\s*(.+)$/.exec(firstLine(gitFile))?.[1], + ) + if (gitDir === undefined) return [] + const commonDir = follow(gitDir, firstLine(path.join(gitDir, 'commondir'))) + return commonDir === undefined ? [gitDir] : [gitDir, commonDir] +} + /** * The ones of `grants` that lie strictly beneath one of `denies`: the * grants that rank above those denies, an allow set further down diff --git a/src/utils/real-path.ts b/src/utils/real-path.ts index 8ffbceebd..d49b04c17 100644 --- a/src/utils/real-path.ts +++ b/src/utils/real-path.ts @@ -14,6 +14,7 @@ function textOf(ask: typeof fs.readlinkSync, at: string): string { if (text.includes('\uFFFD') && !ask(at, 'buffer').equals(Buffer.from(text))) { throw Object.assign(new Error(`EILSEQ: illegal byte sequence, '${at}'`), { code: 'EILSEQ', + path: at, }) } return text @@ -50,6 +51,15 @@ export function realPathOf(p: string): string { return real || '/' } +/** What {@link wayTo} passes that decides where it leads: whoever can put + * something else at one of these can lead it elsewhere. */ +export type WayWatcher = { + /** A link it follows: where the link lies, and what it says. */ + link?: (at: string, target: string) => void + /** A directory it goes up from again, by a `..` in a link's target. */ + left?: (dir: string) => void +} + /** * The kernel's way to `folded`, an absolute path with no `.`, `..` or `//` of * its own: name by name, split at `/` alone, every link followed. `real` is @@ -58,7 +68,10 @@ export function realPathOf(p: string): string { * look for (EACCES), with what the kernel said of it. Whatever else stops the * way is thrown. POSIX only. */ -export function wayTo(folded: string): { +export function wayTo( + folded: string, + watcher: WayWatcher = {}, +): { real: string rest: string[] notThere?: unknown @@ -77,7 +90,10 @@ export function wayTo(folded: string): { } return { real, rest: [name, ...rest], notThere } } - if (name === '..') real = real.slice(0, real.lastIndexOf('/')) + if (name === '..' && real !== '') { + watcher.left?.(real) + real = real.slice(0, real.lastIndexOf('/')) + } continue } const next = `${real}/${name}` @@ -104,8 +120,39 @@ export function wayTo(folded: string): { ) } const target = textOf(fs.readlinkSync, next) + watcher.link?.(next, target) if (target.startsWith('/')) real = '' rest.unshift(...target.split('/')) } return { real, rest } } + +/** + * {@link wayTo}, followed on where it goes up again at once from a name that + * is not there (`missing/../x`): a directory made at that name lets the way + * pass, and a link made there leads it elsewhere. `passed` holds each such + * name, `rest` no `.` or empty name, and `shut` whether `real` lets nobody + * look (EACCES). ELOOP past as many of them as links. + */ +export function wayPastMissingNames( + folded: string, + watcher?: WayWatcher, +): { real: string; rest: string[]; shut: boolean; passed: string[] } { + const passed: string[] = [] + let onward = folded + while (passed.length <= MAX_LINKS) { + const { real, rest, notThere } = wayTo(onward, watcher) + const names = rest.filter(name => name !== '' && name !== '.') + const shut = + (notThere as NodeJS.ErrnoException | undefined)?.code === 'EACCES' + if (shut || names[1] !== '..') return { real, rest: names, shut, passed } + passed.push(`${real}/${names[0]}`) + onward = [real, ...names.slice(2)].join('/') + } + throw Object.assign( + new Error( + `ELOOP: '${folded}' goes up from too many names that are not there`, + ), + { code: 'ELOOP', path: folded }, + ) +} diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 739037ca7..487becfdf 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -14,6 +14,7 @@ export interface RipgrepConfig { * @param target Target directory or file to search * @param abortSignal AbortSignal to cancel the operation * @param config Ripgrep configuration (command and optional args) + * The command is spawned as given: a bare name is searched on the whole PATH. * @returns Array of matching lines (one per line of output) * @throws RipgrepError if ripgrep exits with non-zero status (except exit code 1 which means no matches) */ diff --git a/src/utils/which.ts b/src/utils/which.ts index 4325aab6b..54399924b 100644 --- a/src/utils/which.ts +++ b/src/utils/which.ts @@ -1,28 +1,64 @@ -import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' + +/** A name with a directory part names one file; it is not searched for. */ +export function isPathQualified(bin: string): boolean { + return bin.includes('/') || bin.includes(path.sep) +} /** - * Find the path to an executable, similar to the `which` command. - * Uses Bun.which when running in Bun, falls back to spawnSync for Node.js. - * - * @param bin - The name of the executable to find - * @returns The full path to the executable, or null if not found + * `file` is a regular file this process may execute: what a `PATH` search + * accepts. Not a directory, a file without the execute bit or a dangling link. */ -export function whichSync(bin: string): string | null { - // Check if we're running in Bun - if (typeof globalThis.Bun !== 'undefined') { - return globalThis.Bun.which(bin) +export function isExecutableFile(file: string): boolean { + try { + fs.accessSync(file, fs.constants.X_OK) + return fs.statSync(file).isFile() + } catch { + return false } +} - // Fallback to Node.js implementation - const result = spawnSync('which', [bin], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 1000, - }) - - if (result.status === 0 && result.stdout) { - return result.stdout.trim() - } +/** + * Where `PATH` says a program called `bin` could be, in search order, whether + * or not anything is there: each entry as spelled, and the file it would hold, + * joined as a shell joins them. The empty entry is the current directory. + * `PATH` is read when called, as a spawned child would inherit it. An unset + * or empty one names no place at all. + */ +export function pathCandidates( + bin: string, +): Array<{ entry: string; file: string }> { + return ( + (process.env.PATH || undefined)?.split(path.delimiter).map(entry => ({ + entry, + file: `${entry || '.'}${path.sep}${bin}`, + })) ?? [] + ) +} - return null +/** + * Find the path to an executable as the `which` command would, without + * running one, which would execute whatever `which` comes first on `PATH`. + * The same search under Node.js and Bun: the first `PATH` entry holding a + * regular file of that name this process may execute. A name with a + * directory part is checked where it is and returned as given. + * + * `Bun.which` is not used: without an explicit `PATH` it searches the one the + * process started with, not the current `process.env.PATH` a spawned child + * inherits, and it passes over an empty entry. + * + * Every `PATH` entry counts, the ones a sandboxed command may write among + * them, so this is for a program that runs with the sandbox's authority. One + * that runs with more is found with `findHostHelper`. POSIX only: it knows + * nothing of `PATHEXT`. + * + * @param bin - The name of the executable to find + * @returns The path to the executable, or null if not found + */ +export function whichSync(bin: string): string | null { + if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null + return ( + pathCandidates(bin).find(({ file }) => isExecutableFile(file))?.file ?? null + ) } diff --git a/test/docker-weak-sandbox.test.ts b/test/docker-weak-sandbox.test.ts index 2dbd49595..6618535d9 100644 --- a/test/docker-weak-sandbox.test.ts +++ b/test/docker-weak-sandbox.test.ts @@ -5,7 +5,7 @@ * Gated on SRT_E2E_DOCKER so `npm test` on the host jobs skips it. CI runs it * twice in one container — once holding CAP_SYS_ADMIN, once under * `capsh --drop=cap_sys_admin` — because a uid-0 caller is what the - * capability drop exists for. + * capability drop exists for. Then the same in alpine:3.22, for musl. * * Invoked by CI via: * docker run --rm \ @@ -19,8 +19,10 @@ import { spawnSync } from 'node:child_process' import { mkdirSync, rmSync, + symlinkSync, writeFileSync, readFileSync, + readlinkSync, existsSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -33,11 +35,14 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { const ALLOWED = join(WORK, 'allowed') const DENIED = join(WORK, 'denied') const SECRET = join(WORK, 'secret') + const LINK = join(ALLOWED, 'link') const CONFIG = join(WORK, 'srt.json') // Same policy without the seccomp helper: the command then runs in bwrap's // own namespaces, where --cap-drop ALL is the only thing between it and the // deny mounts. const CONFIG_NO_SECCOMP = join(WORK, 'srt-no-seccomp.json') + // Same policy with the helper's namespaces filter lifted. + const CONFIG_NAMESPACES = join(WORK, 'srt-namespaces.json') // umount(8) reports through /proc/self/mountinfo, which under the helper // belongs to another pid namespace; call the syscall so the kernel's own // errno is what the test reads. @@ -71,6 +76,7 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { mkdirSync(DENIED, { recursive: true }) mkdirSync(SECRET, { recursive: true }) writeFileSync(join(SECRET, 'key'), 'TOPSECRET') + symlinkSync('leads-to', LINK) writeFileSync( UMOUNT_PROBE, [ @@ -78,7 +84,8 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { "libc = ctypes.CDLL('libc.so.6', use_errno=True)", 'for target in sys.argv[1:]:', ' ctypes.set_errno(0)', - ' rc = libc.umount2(target.encode(), 0)', + // UMOUNT_NOFOLLOW, which also reaches a mount on a symbolic link. + ' rc = libc.umount2(target.encode(), 8)', ' e = ctypes.get_errno()', " print('umount2 %s rc=%d errno=%s (%s)' % (", ' target, rc, errno.errorcode.get(e, str(e)), os.strerror(e)))', @@ -89,7 +96,7 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { filesystem: { denyRead: [SECRET], allowWrite: [ALLOWED], - denyWrite: [], + denyWrite: [LINK], }, enableWeakerNestedSandbox: true, } @@ -100,6 +107,14 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { network: { allowedDomains: [], deniedDomains: [] }, }), ) + writeFileSync( + CONFIG_NAMESPACES, + JSON.stringify({ + ...policy, + network: { allowedDomains: [], deniedDomains: [] }, + allowNestedUserNamespaces: true, + }), + ) writeFileSync( CONFIG_NO_SECCOMP, JSON.stringify({ @@ -166,6 +181,26 @@ describe.if(inDocker)('srt end-to-end as uid 0 in a container', () => { expect(existsSync(out)).toBe(false) }) + // The filter refuses the unmount; where it is lifted, the lock does. + it.each([ + ['', CONFIG, 'EPERM'], + [' from a command allowed namespaces', CONFIG_NAMESPACES, 'EINVAL'], + ])( + 'keeps a write-denied symbolic link in place%s', + (_from, config, errno) => { + const r = srt( + `echo SANDBOX-RAN; python3 ${UMOUNT_PROBE} ${LINK}; rm ${LINK} 2>&1`, + config, + ) + + expect(r.stdout).toContain('SANDBOX-RAN') + expect(r.stdout).toContain(`umount2 ${LINK} rc=-1 errno=${errno}`) + // EBUSY, as glibc words it ("Device or resource busy") or musl. + expect(r.stdout).toMatch(/resource busy/i) + expect(readlinkSync(LINK)).toBe('leads-to') + }, + ) + // The kernel would let it: the command holds the capability. it('cannot make a mount namespace of its own (seccomp helper)', () => { const probe = join(WORK, 'mount-namespace-probe.py') diff --git a/test/sandbox/check-dependencies.test.ts b/test/sandbox/check-dependencies.test.ts index 5adbb9bdf..b23b2eb23 100644 --- a/test/sandbox/check-dependencies.test.ts +++ b/test/sandbox/check-dependencies.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as which from '../../src/utils/which.js' import * as platform from '../../src/utils/platform.js' +import * as hostHelpers from '../../src/sandbox/host-helpers.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' // SandboxManager.checkDependencies() must only require ripgrep on Linux, @@ -9,15 +10,23 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' let whichSpy: ReturnType let platformSpy: ReturnType +let hostHelperSpy: ReturnType beforeEach(() => { whichSpy = spyOn(which, 'whichSync') platformSpy = spyOn(platform, 'getPlatform') + // The manager keeps the configuration of whichever suite ran before, and + // findHostHelper judges by it: pin the lookup to the unrestricted case, + // which is the whichSync mocked here. + hostHelperSpy = spyOn(hostHelpers, 'findHostHelper').mockImplementation( + (name: string) => ({ path: which.whichSync(name), skipped: [] }), + ) }) afterEach(() => { whichSpy.mockRestore() platformSpy.mockRestore() + hostHelperSpy.mockRestore() }) describe('SandboxManager.checkDependencies: ripgrep', () => { diff --git a/test/sandbox/credential-deny.test.ts b/test/sandbox/credential-deny.test.ts index c222a1c3b..acbcc737c 100644 --- a/test/sandbox/credential-deny.test.ts +++ b/test/sandbox/credential-deny.test.ts @@ -138,7 +138,7 @@ describe('macOS env -u preamble generation', () => { }) expect( - wrapped.startsWith('env -u GH_TOKEN -u AWS_SECRET_ACCESS_KEY '), + wrapped.startsWith('/usr/bin/env -u GH_TOKEN -u AWS_SECRET_ACCESS_KEY '), ).toBe(true) // The -u flags must precede the VAR=VALUE assignments and sandbox-exec. expect(wrapped.indexOf('-u GH_TOKEN')).toBeLessThan( @@ -245,7 +245,7 @@ describe.if(isSupportedPlatform)( 'printenv MY_API_TOKEN', ) - expect(wrapped.startsWith('env -u MY_API_TOKEN ')).toBe(true) + expect(wrapped.startsWith('/usr/bin/env -u MY_API_TOKEN ')).toBe(true) // -u must precede the first NAME=VALUE assignment and sandbox-exec so // BSD env still treats it as an option. expect(wrapped.indexOf('-u MY_API_TOKEN')).toBeLessThan( diff --git a/test/sandbox/glob-expand.test.ts b/test/sandbox/glob-expand.test.ts index 38e8f5827..90a4fe1fc 100644 --- a/test/sandbox/glob-expand.test.ts +++ b/test/sandbox/glob-expand.test.ts @@ -1005,6 +1005,14 @@ describe.if(!isWindows)('walkGlobPattern', () => { join(root, 'outside', 'cert', 'x.pem'), join(root, 'outside', 'cert9x.pem'), ]) + // A negated set is one character of a name, whatever it leaves out. + expect(found('*/cert[!s/]x.pem')).toEqual([ + join(root, 'outside', 'cert9x.pem'), + ]) + expect(found('*/cert[^0-8]x.pem')).toEqual([ + join(root, 'outside', 'cert9x.pem'), + join(root, 'outside', 'certsx.pem'), + ]) } finally { rmSync(root, { recursive: true, force: true }) } @@ -2410,19 +2418,20 @@ describe('globToRegex (shared)', () => { expect(new RegExp(letters).test('/tmp/test/1bc.txt')).toBe(false) }) - it('never negates a bracket set: ^ and ! are members of it', () => { + it('negates a bracket set written with ^ or !', () => { // Both spellings used for negation elsewhere (regex `^`, gitignore `!`) - // land in the set as ordinary characters, so such a pattern matches - // fewer names than its author meant, not more. + // negate the set rather than joining it as ordinary characters. const caret = globToRegex('/tmp/test/file[^0-9].txt') - expect(new RegExp(caret).test('/tmp/test/file^.txt')).toBe(true) - expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(true) - expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(false) + expect(new RegExp(caret).test('/tmp/test/fileA.txt')).toBe(true) + expect(new RegExp(caret).test('/tmp/test/file3.txt')).toBe(false) const bang = globToRegex('/tmp/test/file[!0-9].txt') - expect(new RegExp(bang).test('/tmp/test/file!.txt')).toBe(true) - expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(true) - expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(false) + expect(new RegExp(bang).test('/tmp/test/fileA.txt')).toBe(true) + expect(new RegExp(bang).test('/tmp/test/file3.txt')).toBe(false) + + // The character that negates the set is not also a member of it. + expect(new RegExp(globToRegex('/tmp/[^^]')).test('/tmp/^')).toBe(false) + expect(new RegExp(globToRegex('/tmp/[!!]')).test('/tmp/!')).toBe(false) }) it('treats a bracket that opens no set as a literal character', () => { diff --git a/test/sandbox/glob-pattern.test.ts b/test/sandbox/glob-pattern.test.ts index 1aba7b277..07728f32b 100644 --- a/test/sandbox/glob-pattern.test.ts +++ b/test/sandbox/glob-pattern.test.ts @@ -11,8 +11,15 @@ const text = (t: string): GlobToken => ({ kind: Kind.Text, text: t }) const set = (members: string, matchesSeparator = false): GlobToken => ({ kind: Kind.Set, members, + negated: false, matchesSeparator, }) +const allBut = (members: string): GlobToken => ({ + kind: Kind.Set, + members, + negated: true, + matchesSeparator: false, +}) const SEPARATOR: GlobToken = { kind: Kind.Separator } const ANY_CHAR: GlobToken = { kind: Kind.AnyChar } const STAR: GlobToken = { kind: Kind.Star } @@ -73,8 +80,8 @@ describe('parseGlob', () => { ['[-a]', [set('-a')]], ['[a-]', [set('a-')]], ['[a-a]', [set('a-a')]], - ['[^a]', [set('^a')]], - ['[!a]', [set('!a')]], + ['[a^]', [set('a^')]], + ['[a!]', [set('a!')]], ['[a[b]', [set('a[b')]], ['[a]]', [set('a'), text(']')]], ['[s/]', [set('s/', true)]], @@ -87,11 +94,35 @@ describe('parseGlob', () => { expect(parseGlob(pattern)).toEqual(tokens) }) + it.each<[string, GlobToken[]]>([ + ['[^a]', [allBut('a')]], + ['[!a]', [allBut('a')]], + ['x[!a-z]y', [text('x'), allBut('a-z'), text('y')]], + // Only the first of them negates. + ['[!!]', [allBut('!')]], + ['[^^]', [allBut('^')]], + ['[!^]', [allBut('^')]], + ['[^!a]', [allBut('!a')]], + ['[!-a]', [allBut('-a')]], + ['[!*]', [allBut('*')]], + // Never the separator, be it a member or not. + ['[!s/]', [allBut('s/')]], + ['[!+-9]', [allBut('+-9')]], + ['[!a]]', [allBut('a'), text(']')]], + ])('reads %p as a negated set', (pattern, tokens) => { + expect(parseGlob(pattern)).toEqual(tokens) + }) + it.each<[string, GlobToken[]]>([ ['[z-a]', [text('[z-a]')]], ['[user-id]', [text('[user-id]')]], ['[]', [text('[]')]], ['[]a]', [text('[]a]')]], + ['[!]', [text('[!]')]], + ['[^]', [text('[^]')]], + ['[!]a]', [text('[!]a]')]], + ['[!z-a]', [text('[!z-a]')]], + ['[^user-id]', [text('[^user-id]')]], ['a]b', [text('a]b')]], ['a[b', [text('a[b')]], ['a[b[c', [text('a[b[c')]], @@ -106,6 +137,7 @@ describe('parseGlob', () => { it.each<[string, GlobToken[]]>([ ['[z-a*]', [text('[z-a'), STAR, text(']')]], + ['[!z-a*]', [text('[!z-a'), STAR, text(']')]], ['[z-a/?]', [text('[z-a'), SEPARATOR, ANY_CHAR, text(']')]], ['a[b[c*', [text('a[b[c'), STAR]], ['[**/', [text('['), GLOBSTAR_SLASH]], @@ -173,8 +205,18 @@ function referenceGlobMatch(pattern: string, pathText: string): boolean { if (head === '?') return pathText[s] !== '/' && rest(p + 1, s + 1) if (head === '[' && p >= bracketsAreTextUntil) { const close = pattern.indexOf(']', p + 1) - const has = close === -1 ? undefined : setOf(pattern.slice(p + 1, close)) - if (has !== undefined) return has(pathText[s]!) && rest(close + 1, s + 1) + const negated = pattern[p + 1] === '!' || pattern[p + 1] === '^' + const has = + close === -1 + ? undefined + : setOf(pattern.slice(p + (negated ? 2 : 1), close)) + if (has !== undefined) { + const char = pathText[s]! + return ( + (negated ? !has(char) && char !== '/' : has(char)) && + rest(close + 1, s + 1) + ) + } bracketsAreTextUntil = close === -1 ? pattern.length : close + 1 } return pathText[s] === head && from(p + 1, s + 1, bracketsAreTextUntil) @@ -219,17 +261,24 @@ describe('globToRegex', () => { it('agrees with a reference matcher over generated patterns and paths', () => { const piece = fc.constantFrom( - ...'[[]]--**??//az09+.^!\\', + ...'[[]]--**??//az09+,.^!\\', '**/', '[a-z]', '[z-a]', '[+-9]', '[*]', '[]', + '[!a-z]', + '[^09]', + '[!z-a]', + '[!-a]', + '[!]', + '[+--]', + '[--0]', ) const pattern = fc.array(piece, { maxLength: 8 }).map(p => p.join('')) // A path like the pattern, so that a match is not rare, or any at all. - const char = fc.constantFrom(...'[]-*?/az09+.^!\\') + const char = fc.constantFrom(...'[]-*?/az09+,.^!\\') const pathFor = (p: string): fc.Arbitrary => fc.oneof( fc.array(char, { maxLength: 6 }).map(chars => chars.join('')), diff --git a/test/sandbox/glob-walk.property.test.ts b/test/sandbox/glob-walk.property.test.ts index 80a0f4d3d..0ffb98767 100644 --- a/test/sandbox/glob-walk.property.test.ts +++ b/test/sandbox/glob-walk.property.test.ts @@ -30,7 +30,8 @@ import { isWindows } from '../helpers/platform.js' /** Names that put the interesting readings next to each other: a bracket set * that can match a separator (`[+-9]` spans `/`), a `**` written against * text, a name that is a prefix of another, a wildcard that is a member of a - * set, and brackets that spell no set (`b-a` runs backwards). */ + * set, brackets that spell no set (`b-a` runs backwards), and negated sets, + * which never match one, whatever their members. */ const NAME = fc.constantFrom( 'a', 'b', @@ -43,6 +44,7 @@ const NAME = fc.constantFrom( 's', '*', '[b-a]', + '[!b-a]', ) const SEGMENT = fc.constantFrom( @@ -61,6 +63,11 @@ const SEGMENT = fc.constantFrom( '[a*]', '**.pem', '[b-a]', + '[!ab]', + '[^s/]', + '[!+-9]', + 'cert[!x]', + '[!b-a]', ) const LAST_SEGMENT = fc.constantFrom( @@ -71,6 +78,8 @@ const LAST_SEGMENT = fc.constantFrom( '?.pem', '[xy].pem', '[+-9].pem', + '[!x].pem', + '[^+-9].pem', ) const PATTERN_TAIL = fc diff --git a/test/sandbox/host-helpers.test.ts b/test/sandbox/host-helpers.test.ts new file mode 100644 index 000000000..44c633d00 --- /dev/null +++ b/test/sandbox/host-helpers.test.ts @@ -0,0 +1,532 @@ +import { afterEach, beforeEach, expect, it, mock, spyOn } from 'bun:test' +import type { Mock } from 'bun:test' +import * as childProcess from 'node:child_process' +import { EventEmitter } from 'node:events' +import * as fs from 'node:fs' +import * as os from 'node:os' +import { dirname, join } from 'node:path' +import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import * as hostHelpers from '../../src/sandbox/host-helpers.js' +import * as javaAgent from '../../src/sandbox/java-proxy-agent.js' +import * as linux from '../../src/sandbox/linux-sandbox-utils.js' +import * as macos from '../../src/sandbox/macos-sandbox-utils.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import * as platformUtils from '../../src/utils/platform.js' +import { whichSync } from '../../src/utils/which.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * The programs the library itself runs on the host are never taken from a + * place the sandboxed command may write. The fixture: a project directory in + * `allowWrite` whose `node_modules/.bin` leads PATH, holding files named like + * the helpers. Each leaves a marker behind if run; no test may find it. + */ +const { findHostHelper, hostSearchPath, writablePlaces } = hostHelpers +const unavailable = hostHelpers.describeUnavailableHostHelper +const warning = hostHelpers.writableNamedHelperWarning +const posix = it.if(!isWindows) +const onLinux = it.if(isLinux) +const BWRAP_CAN_NAMESPACE = isLinux && bwrapCanNamespace() +const savedPath = process.env.PATH ?? '' +const savedCwd = process.cwd() +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const base = fs.realpathSync(fs.mkdtempSync(join(os.tmpdir(), 'host-helpers-'))) +const marker = `${base}/a-planted-file-was-run` +const project = `${base}/project` +const projectBin = `${project}/node_modules/.bin` +const safe = `${base}/safe` +const elsewhere = `${base}/elsewhere` +// A made-up helper name for the cases that are about the search alone. +const HELPER = 'srt-test-helper' +const planted = `${projectBin}/${HELPER}` +const outside = `${safe}/${HELPER}` +const writable = [project] +const inProject = `inside the allowed write path ${project}` +const replaceable = 'the sandboxed command can replace that file' + +// An executable that leaves the marker behind if anything ever runs it. +function plant(file: string): string { + fs.mkdirSync(dirname(file), { recursive: true }) + const script = `#!/bin/sh\necho "$0" >> '${marker}'\n` + fs.writeFileSync(file, script, { mode: 0o755 }) + return file +} + +function link(target: string, file: string): void { + fs.mkdirSync(dirname(file), { recursive: true }) + fs.symlinkSync(target, file) +} + +// What the search passes over when a helper's only copy is the planted one, +// and what is then said. +const skipped = (helper: string): hostHelpers.SkippedHostHelper[] => [ + { path: `${projectBin}/${helper}`, reason: inProject, writePath: project }, +] +const refusal = (helper: string, option: string): string => + unavailable(helper, option, { path: null, skipped: skipped(helper) }) + +beforeEach(async () => { + // Other suites leave a configuration, and wraps they did not clean up. + await SandboxManager.reset() + for (const name of [HELPER, 'bwrap', 'socat', 'rg', 'bash', 'npm', 'node']) + plant(`${projectBin}/${name}`) + plant(outside) + plant(`${elsewhere}/${HELPER}`) + link(planted, `${base}/linked/${HELPER}`) + link(elsewhere, `${project}/tools`) + link(project, `${base}/alias`) + link(`${project}/node_modules/../../safe/${HELPER}`, `${base}/up/${HELPER}`) + link('/', `${project}/dist`) + // Something only the scan finds: a shell startup file below the cwd. + fs.mkdirSync(`${project}/nested`) + fs.writeFileSync(`${project}/nested/.bashrc`, '') +}) + +afterEach(async () => { + mock.restore() + Object.defineProperty(process, 'platform', platform) + process.env.PATH = savedPath + process.chdir(savedCwd) + seccomp.resetGlobalNpmPathsForTesting() + // The manager keeps its configuration: one that names nothing of this file's. + SandboxManager.updateConfig({ + ...config, + filesystem: { ...config.filesystem, allowWrite: [] }, + }) + await SandboxManager.reset() + const ran = fs.existsSync(marker) && fs.readFileSync(marker, 'utf8') + fs.rmSync(base, { recursive: true, force: true }) + expect(ran).toBe(false) +}) + +posix.each<[string, Parameters[0], string[]]>([ + ['writes anywhere', 'anywhere', []], + ['the root among them', [project, '/'], []], + ['a trailing slash', [`${project}/`], [project]], + ['an entry that is not there', [`${base}/absent`], [`${base}/absent`]], + ['a home directory with no name', ['~', project], [project]], + // The command can make such a link wherever one entry lies inside another, + // and the wrap binds nothing for it: it switches nothing off, widens nothing. + ['a link to the root', [`${project}/dist`], [`${project}/dist`]], + ['a link out of its place', [`${project}/tools`], [`${project}/tools`]], +])('writablePlaces: %s', (_, allowed, places) => { + spyOn(os, 'homedir').mockReturnValue('') + expect(writablePlaces(allowed)).toEqual(places) +}) + +posix('writablePlaces: what reads as a pattern is a name on Linux', () => { + const names = [`${project}/src/**/*.ts`, `${safe}/w[1]`, '/x[1]'] + const entries = names.map(name => `${name}/`) + const on = spyOn(platformUtils, 'getPlatform').mockReturnValue('linux') + expect(writablePlaces(entries)).toEqual(names) + on.mockReturnValue('macos') + expect(writablePlaces(entries.slice(0, 2))).toEqual([`${project}/src`, safe]) + // It starts from the root. + expect(writablePlaces(entries)).toEqual([]) +}) + +posix('writablePlaces: no policy known yet', () => { + process.chdir(elsewhere) + fs.rmSync(elsewhere, { recursive: true }) + const defaults = writablePlaces(undefined) + expect(defaults).toContain('/dev/null') + process.chdir(safe) + expect(writablePlaces(undefined)).toEqual([safe, ...defaults]) +}) + +// What, the PATH entry it is found through, the place that rules it out. +posix.each([ + ['a copy in it', projectBin, undefined], + ['a link to a file in it', `${base}/linked`, planted], + // It leads out today; whoever may write the directory it is kept in decides + // where it leads tomorrow. + ['a PATH entry that is a link kept in it', `${project}/tools`, undefined], + ['that link through an alias', `${base}/alias/tools`, `${project}/tools`], + ['a link that goes up from it', `${base}/up`, `${project}/node_modules`], +])('the search passes over %s', (_, entry, through) => { + const path = `${entry}/${HELPER}` + const reason = (through ? `leads through ${through}, ` : '') + inProject + const skipped = [{ path, reason, writePath: project }] + process.env.PATH = `${entry}:${safe}` + // The plain search, for what runs inside the sandbox, takes the first. + expect(whichSync(HELPER)).toBe(path) + expect(findHostHelper(HELPER, writable)).toEqual({ path: outside, skipped }) + expect(hostSearchPath(writable, [HELPER])).toBe(safe) + // With writes allowed everywhere no place is better than another. + expect(findHostHelper(HELPER, [])).toEqual({ path, skipped: [] }) + process.env.PATH = entry + expect(findHostHelper(HELPER, writable)).toEqual({ path: null, skipped }) + // Named outright it is used as given, and warned about. + expect(findHostHelper(path, writable)).toEqual({ path, skipped: [] }) + expect(warning('o', path, writable)).toBe( + `o ${path} ${through ? '' : 'is '}${reason}: ${replaceable}`, + ) +}) + +posix('the search never uses a relative PATH entry, nor the empty one', () => { + plant(`${elsewhere}/bin/${HELPER}`) + process.chdir(elsewhere) + process.env.PATH = `:.:bin:${safe}` + const skipped = ['', '.', 'bin'].map(entry => ({ + path: `${entry || '.'}/${HELPER}`, + reason: `the PATH entry '${entry}' is relative`, + })) + for (const places of [writable, []]) { + expect(findHostHelper(HELPER, places)).toEqual({ path: outside, skipped }) + expect(hostSearchPath(places, [])).toBe(safe) + } +}) + +posix('the search takes the first program outside', () => { + fs.mkdirSync(`${project}/no-program/${HELPER}`, { recursive: true }) + // By path segment: this one is beside the project, not in it. + const path = plant(`${project}-tools/${HELPER}`) + process.env.PATH = `${project}/no-program:${project}-tools:${safe}` + expect(findHostHelper(HELPER, writable)).toEqual({ path, skipped: [] }) + expect(findHostHelper(project, writable)).toEqual({ path: null, skipped: [] }) + // `..` in an entry is the file system's to read: after a link, this one's + // copy is another directory's. + link(elsewhere, `${safe}/jump`) + link(planted, `${base}/${HELPER}`) + process.env.PATH = `${safe}/jump/..:${safe}` + expect(findHostHelper(HELPER, writable).skipped).toHaveLength(1) + expect(hostSearchPath(writable, [HELPER])).toBe(safe) +}) + +posix('hostSearchPath leaves out what no helper would be taken from', () => { + // Whatever bears the name is judged: the command could supply the rest. + link(`${project}/not-there-yet`, `${base}/dangling-in/${HELPER}`) + link(`${base}/nowhere`, `${base}/dangling-out/${HELPER}`) + link(HELPER, `${base}/loop/${HELPER}`) + const copies = ['dangling-in', 'dangling-out', 'loop'].map(d => join(base, d)) + const all = [projectBin, `${base}/absent`, ...copies, safe, '/usr/bin'] + process.env.PATH = all.join(':') + expect(hostSearchPath(writable, []).split(':')).toEqual(all.slice(2)) + expect(hostSearchPath(writable, ['x', HELPER])).toBe(`${safe}:/usr/bin`) + expect(hostSearchPath([], [HELPER]).split(':')).toEqual(all) + // An empty PATH would mean the current directory to a shell. + process.env.PATH = projectBin + expect(hostSearchPath(writable, [])).toBe('/dev/null') + delete process.env.PATH + expect(hostSearchPath(writable, [])).toBe('/dev/null') +}) + +posix('what is said of a helper that may not be used', () => { + const none = { path: null, skipped: [] } + expect(unavailable('rg', 'o', none)).toBe('rg was not found on PATH') + expect(unavailable('/rg', 'o', none)).toBe('/rg is not an executable file') + expect(unavailable('rg', 'o', none, 'not installed')).toBe('not installed') + const two = ['/w/rg', './rg'].map(path => ({ path, reason: 'why' })) + expect(unavailable('rg', 'o', { path: null, skipped: two }, 'unused')).toBe( + 'rg was not found on PATH outside the paths the sandboxed command may write. ' + + 'Passed over: /w/rg (why); ./rg (why). Install it outside those paths, ' + + 'put the directory that holds it on PATH, or name it by its path with o.', + ) + process.chdir(projectBin) + expect(warning('o', `./${HELPER}`, writable)).toBe( + `o ./${HELPER} is ${inProject}: ${replaceable}`, + ) + // A bare name names no file: argv0 mode's applyPath. + for (const file of [HELPER, undefined, outside, `${base}/absent`]) + expect(warning('o', file, writable)).toBeUndefined() +}) + +const NAMES = ['rg', 'bwrap', 'socat', 'bash'] +const real = Object.fromEntries(NAMES.map(name => [name, whichSync(name)!])) +const config = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [project], denyWrite: [] }, +} +// Never run: for a socket any path that is there will do. +const RELAYED = { + needsNetworkRestriction: true, + httpSocketPath: safe, + socksSocketPath: elsewhere, +} +const NAMED = { + bwrapPath: `${projectBin}/bwrap`, + socatPath: `${projectBin}/socat`, + binShell: `${projectBin}/bash`, + ripgrepConfig: { command: real.rg }, +} + +// A directory of links to the real programs named, and to no other. +function tools(names: string[], dir = `${base}/real`): string { + fs.mkdirSync(dir, { recursive: true }) + for (const name of names) link(real[name], `${dir}/${name}`) + return dir +} + +// The project is marked literal, which counts like any other entry, and the +// link to the root is among the write paths. +function wrap( + params: Partial = {}, + allowOnly = [`${project}/dist`], +): Promise { + return linux.wrapCommandWithSandboxLinux({ + command: 'echo wrapped-ok', + needsNetworkRestriction: false, + writeConfig: { + allowOnly, + literalAllowOnly: [project], + denyWithinAllow: [], + }, + ...params, + }) +} + +onLinux.each<[string, Partial]>([ + ['the real ones', {}], + // It may ask bubblewrap about itself: the copy it runs, never PATH's. + ['the real ones, relayed', { ...RELAYED, allowAllUnixSockets: true }], + ['the ones named outright, as given', { ...RELAYED, ...NAMED }], +])('a wrap names its programs by their paths: %s', async (_, params) => { + const { bwrapPath = real.bwrap, binShell = real.bash } = params + process.chdir(project) + process.env.PATH = `${projectBin}:${savedPath}` + const wrapped = await wrap(params) + expect(wrapped).toStartWith(`${bwrapPath} `) + expect(wrapped).toContain(` -- ${binShell} -c `) + // Each relay names socat twice: for both families, and for IPv4 alone. + expect([...(wrapped.match(/[^\s'"]*socat(?= TCP\d-LISTEN)/g) ?? [])]).toEqual( + Array(params.httpSocketPath ? 4 : 0).fill(params.socatPath ?? real.socat), + ) + // Its own binds agree about the link. + expect(wrapped).toContain(`--bind ${project} ${project}`) + expect(wrapped).not.toContain(`--bind ${project}/dist`) + // Only the scan finds the nested file: the real ripgrep ran. + expect(wrapped).toContain(`${project}/nested/.bashrc`) + if (BWRAP_CAN_NAMESPACE && !params.httpSocketPath) { + const options = { shell: true, encoding: 'utf8' } as const + expect(childProcess.spawnSync(wrapped, options).stdout).toBe('wrapped-ok\n') + } +}) + +onLinux.each([ + ['bwrap', 'bwrapPath', undefined], + ['socat', 'socatPath', undefined], + ['bash', 'binShell', undefined], + [HELPER, 'binShell', HELPER], +])('a wrap refuses for want of %s', async (helper, option, binShell) => { + // Everything else it needs is there, outside the project. + process.env.PATH = `${projectBin}:${tools(NAMES.filter(n => n !== helper))}` + const error: unknown = await wrap({ ...RELAYED, binShell }).catch( + (thrown: unknown) => thrown, + ) + expect(error).toBeInstanceOf(linux.LinuxSandboxProfileError) + expect(error).toMatchObject({ + code: 'host_helper_unavailable', + message: refusal(helper, option), + // The same as fields, for a caller that words its own message. + cause: { helper, skipped: skipped(helper) }, + }) +}) + +onLinux('a wrap goes on without a ripgrep, and says so', async () => { + // The policy counts, not the working directory. + process.chdir(safe) + process.env.PATH = `${projectBin}:${tools(['bwrap', 'bash'])}` + const { result, warnings } = await withCapturedWarnings(() => + wrap({}, [safe]), + ) + expect(warnings.join()).toContain( + `ripgrep scan failed: LinuxSandboxProfileError: ${refusal('rg', 'ripgrep.command')}`, + ) + // What needs no scan is still denied. + expect(result).toContain(`--ro-bind /dev/null ${safe}/.bashrc`) +}) + +onLinux('a wrap that restricts no write searches the whole PATH', async () => { + process.chdir(project) + process.env.PATH = `${tools(['bwrap'], `${project}/real`)}:${savedPath}` + const unrestricted = { writeConfig: undefined, unsetEnvVars: ['X'] } + expect(await wrap(unrestricted)).toStartWith(`${project}/real/bwrap `) + // But for where the session's earlier commands could write. + expect(await wrap({ ...unrestricted, alsoWritable: writable })).toStartWith( + `${real.bwrap} `, + ) +}) + +const refusals = [ + refusal('rg', 'ripgrep.command'), + refusal('bwrap', 'bwrapPath'), + refusal('socat', 'socatPath'), +] + +// In a process of its own: nothing takes a configuration out of the manager. +onLinux('with no configuration the checks judge by the cwd', () => { + const { stdout } = childProcess.spawnSync( + process.execPath, + [ + '-e', + `const { SandboxManager: s } = await import('${import.meta.dir}/../../src/index.ts') + const checks = [s.checkDependencies(), await s.checkDependenciesAsync()] + console.log(JSON.stringify(checks.map(check => check.errors)))`, + ], + { cwd: project, encoding: 'utf8', env: { PATH: projectBin } }, + ) + expect(JSON.parse(stdout)).toEqual([refusals, refusals]) +}) + +onLinux.each([ + ['checkDependencies', SandboxManager.checkDependencies], + ['checkDependenciesAsync', SandboxManager.checkDependenciesAsync], +])('%s takes a configuration it cannot read for none', async (_, check) => { + SandboxManager.updateConfig({ + ...config, + filesystem: { allowWrite: [{}] }, + } as never) + process.chdir(project) + process.env.PATH = `${projectBin}:${tools([])}` + expect((await check()).errors).toEqual(refusals) + expect(linux.checkLinuxDependencies().errors).toEqual(refusals.slice(1)) + expect(linux.getLinuxDependencyStatus().hasBwrap).toBe(false) + // Of a helper that is nowhere it says what it always said. + process.env.PATH = `${base}/real` + expect((await check()).errors).toEqual([ + 'ripgrep (rg) not found', + 'bubblewrap (bwrap) not installed', + 'socat not installed', + ]) +}) + +onLinux( + 'the manager judges by its configuration, and hands it on', + async () => { + process.env.PATH = `${projectBin}:${tools(['bwrap', 'socat'])}` + expect(await SandboxManager.initialize(config).catch(String)).toBe( + `Error: Sandbox dependencies not available: ${refusal('rg', 'ripgrep.command')}`, + ) + const lookups = [ + spyOn(seccomp, 'getApplySeccompBinaryPath'), + spyOn(seccomp, 'getApplySeccompBinaryPathAsync'), + spyOn(javaAgent, 'getJavaProxyAgentJarPathAsync'), + ] + // The bridges run on the host: not the planted socat. + process.env.PATH = `${projectBin}:${savedPath}` + await SandboxManager.initialize(config) + await wrap() + linux.getLinuxDependencyStatus({ writable }) + // A call's own, narrower write paths do not clear the session's. + const own = { ...config.filesystem, allowWrite: [elsewhere] } + expect( + await SandboxManager.wrapWithSandbox('true', undefined, { + filesystem: own, + }), + ).toStartWith(`${real.bwrap} `) + expect(lookups.map(lookup => lookup.mock.calls.length)).toEqual([6, 1, 1]) + for (const call of lookups.flatMap(lookup => lookup.mock.calls)) + expect(call[1]).toContain(project) + }, +) + +onLinux('the dependency check warns of a helper named outright', () => { + const { bwrapPath, socatPath, binShell: applyPath } = NAMED + const command = `${projectBin}/rg` + SandboxManager.updateConfig({ + ...config, + ...{ bwrapPath, socatPath, ripgrep: { command }, seccomp: { applyPath } }, + }) + expect(SandboxManager.checkDependencies()).toEqual({ + errors: [], + warnings: [ + `ripgrep.command ${command} is ${inProject}: ${replaceable}`, + `bwrapPath ${bwrapPath} is ${inProject}: ${replaceable}`, + `socatPath ${socatPath} is ${inProject}: ${replaceable}`, + `seccomp.applyPath ${applyPath} is ${inProject}: ${replaceable}`, + ], + }) + // The bwrap it names is asked its version. The check runs nothing else. + expect(fs.readFileSync(marker, 'utf8').split(`${bwrapPath}\n`).join('')).toBe( + '', + ) + fs.rmSync(marker) +}) + +// `npm root -g` is asked for a global install of this package. npm is a script +// and looks `node` up by name, so the rule is put to a PATH. Here neither file +// is in one of the package's own locations, and an npm outside the project +// answers. +function npmAnswers(): Mock { + const script = `#!/bin/sh\necho '${base}/global'\n` + fs.writeFileSync(`${safe}/npm`, script, { mode: 0o755 }) + link(`${projectBin}/node`, `${base}/linked/node`) + process.env.PATH = `${projectBin}:${base}/linked:${safe}:${savedPath}` + const existed = fs.existsSync + spyOn(fs, 'existsSync').mockImplementation( + file => + (String(file).startsWith(base) || + !/(apply-seccomp|\.jar)$/.test(String(file))) && + existed(file), + ) + return spyOn(hostHelpers, 'hostSearchPath') +} + +// Lookups are remembered by the path named: an absent one, each time another. +let lookups = 0 +const absent = (): string => `${base}/absent-${lookups++}` + +posix.each<(places?: string[]) => unknown>([ + seccomp.getGlobalNpmPaths, + seccomp.getGlobalNpmPathsAsync, + places => seccomp.getApplySeccompBinaryPath(absent(), places), + places => seccomp.getApplySeccompBinaryPathAsync(absent(), places), + places => javaAgent.getJavaProxyAgentJarPathAsync(absent(), places), +])('npm is looked up outside the write paths, and once: %#', async ask => { + const searchPath = npmAnswers() + // With no policy known the working directory counts. + for (const places of [writable, undefined]) { + if (!places) process.chdir(project) + seccomp.resetGlobalNpmPathsForTesting() + await ask(places) + await ask(places) + } + expect( + searchPath.mock.results.map(({ value }) => String(value).split(':')[0]), + ).toEqual([safe, safe]) +}) + +posix.each([ + seccomp.getApplySeccompBinaryPath, + seccomp.getApplySeccompBinaryPathAsync, +])('a global install the command may write is passed over: %#', async find => { + const helper = plant( + `${base}/global/@anthropic-ai/sandbox-runtime/vendor/seccomp/${process.arch}/apply-seccomp`, + ) + npmAnswers() + const named = absent() + expect(await find(named, [project, `${base}/global`])).toBeNull() + // Remembered by the places it was judged by, too. + expect(await find(named, writable)).toBe(helper) +}) + +posix('on Windows npm is given the environment as it is', () => { + const execSync = spyOn(childProcess, 'execSync').mockReturnValue('') + Object.defineProperty(process, 'platform', { value: 'win32' }) + seccomp.getGlobalNpmPaths(writable) + expect(execSync.mock.calls[0]?.[1]?.env).toBe(process.env) +}) + +posix( + 'macOS: the shell, under sandbox-exec, is found on the whole PATH', + () => { + process.env.PATH = `${projectBin}:${savedPath}` + const wrapped = macos.wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig: { allowOnly: [project], denyWithinAllow: [] }, + }) + expect(wrapped).toMatch(/^\/usr\/bin\/env .* \/usr\/bin\/sandbox-exec -p /s) + expect(wrapped).toContain(` ${projectBin}/bash -c `) + }, +) + +posix('macOS: the log monitor is spawned as /usr/bin/log', () => { + const child = new EventEmitter() as never + const spawn = spyOn(childProcess, 'spawn').mockReturnValue(child) + macos.startMacOSSandboxLogMonitor(mock(), undefined, String) + expect(spawn.mock.calls[0]?.[0]).toBe('/usr/bin/log') +}) diff --git a/test/sandbox/linux-bwrap-args-file.test.ts b/test/sandbox/linux-bwrap-args-file.test.ts index cb3b66b48..fbe84df71 100644 --- a/test/sandbox/linux-bwrap-args-file.test.ts +++ b/test/sandbox/linux-bwrap-args-file.test.ts @@ -59,7 +59,7 @@ describe.if(isLinux)('bwrap --args for over-long profiles', () => { // The one rendered shape: the profile's path, then the options left before // and the words left after `--args 9`. const VIA_ARGS_FILE = - /^\/bin\/sh -c 'exec 9<"\$1" && shift && exec "\$@"' srt-args (\S+) bwrap (.*?) ?--args 9 (.*)$/s + /^\/bin\/sh -c 'exec 9<"\$1" && shift && exec "\$@"' srt-args (\S+) \/\S*\/bwrap (.*?) ?--args 9 (.*)$/s const MODULE = join( import.meta.dir, '../../src/sandbox/linux-sandbox-utils.ts', diff --git a/test/sandbox/linux-dependency-error.test.ts b/test/sandbox/linux-dependency-error.test.ts index dc4e887be..b961cd449 100644 --- a/test/sandbox/linux-dependency-error.test.ts +++ b/test/sandbox/linux-dependency-error.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' import * as childProcess from 'node:child_process' import * as which from '../../src/utils/which.js' import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import * as hostHelpers from '../../src/sandbox/host-helpers.js' import { CAP_SETFCAP, CAP_SETFCAP_MISSING_MESSAGE, @@ -22,6 +23,7 @@ const lacksSetfcap = !processHasBoundingCapability(CAP_SETFCAP) // just the piece it's exercising. spyOn patches the export binding, so // linux-sandbox-utils' own imports see the replacement. let whichSpy: ReturnType +let hostHelperSpy: ReturnType let applySpy: ReturnType let euidSpy: ReturnType | undefined let spawnSyncSpy: ReturnType @@ -30,18 +32,22 @@ let spawnSyncSpy: ReturnType const bwrapExiting = (status: number, stderr = '') => ({ status, signal: null, pid: 1, output: [], stdout: '', stderr }) as never -// The spawns that are not the `bwrap --version` probe every caller runs: -// the ones that ask about capabilities. +// The spawns that are not the bare `bwrap --version` every caller runs: the +// ones that ask about capabilities, which run `--version` inside a sandbox. const capabilityProbes = (): unknown[] => spawnSyncSpy.mock.calls.filter( (call: unknown[]) => - !(Array.isArray(call[1]) && call[1].includes('--version')), + !(Array.isArray(call[1]) && call[1].join(' ') === '--version'), ) beforeEach(() => { whichSpy = spyOn(which, 'whichSync').mockImplementation( (bin: string) => `/usr/bin/${bin}`, ) + // What is passed over is host-helpers.test.ts's. + hostHelperSpy = spyOn(hostHelpers, 'findHostHelper').mockImplementation( + (name: string) => ({ path: which.whichSync(name), skipped: [] }), + ) applySpy = spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue( '/path/to/apply-seccomp', ) @@ -57,6 +63,7 @@ beforeEach(() => { afterEach(() => { whichSpy.mockRestore() + hostHelperSpy.mockRestore() applySpy.mockRestore() euidSpy?.mockRestore() spawnSyncSpy.mockRestore() @@ -147,7 +154,7 @@ describe('checkLinuxDependencies', () => { test('passes custom applyPath through to the resolver', () => { checkLinuxDependencies({ seccompConfig: { applyPath: '/custom/apply' } }) - expect(applySpy).toHaveBeenCalledWith('/custom/apply') + expect(applySpy).toHaveBeenCalledWith('/custom/apply', expect.anything()) }) test('argv0 mode: no seccomp warning even when binary lookup would fail', () => { @@ -206,12 +213,15 @@ describe('uid0SandboxError', () => { bwrap: '/usr/bin/bwrap-refuses', }), ).toBe(`${CAP_SETFCAP_MISSING_MESSAGE} (bubblewrap: ${REFUSAL})`) + // The command inside is the same bubblewrap by its path, never a bare name. + expect(spawnSyncSpy.mock.calls[0]?.[0]).toBe('/usr/bin/bwrap-refuses') expect(spawnSyncSpy.mock.calls[0]?.[1]).toEqual([ '--unshare-user', '--dev-bind', '/', '/', - 'true', + '/usr/bin/bwrap-refuses', + '--version', ]) }) diff --git a/test/sandbox/linux-unmakeable-mount-point.test.ts b/test/sandbox/linux-unmakeable-mount-point.test.ts index e6a6c40ba..f2495dc85 100644 --- a/test/sandbox/linux-unmakeable-mount-point.test.ts +++ b/test/sandbox/linux-unmakeable-mount-point.test.ts @@ -7,6 +7,7 @@ import { wrapCommandWithSandboxLinux, cleanupBwrapMountPoints, } from '../../src/sandbox/linux-sandbox-utils.js' +import { whichSync } from '../../src/utils/which.js' import { isLinux } from '../helpers/platform.js' import { countMounts, mountsOf } from '../helpers/bwrap-argv.js' import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' @@ -130,6 +131,13 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { value: { ...real.statfsSync(p as string), type: V9FS_MAGIC }, })) + // The wrap is told which helpers to run: one case allows writes to /usr, and + // a copy on PATH that lies inside a write path is passed over. + const HELPERS = { + bwrapPath: whichSync('bwrap') ?? undefined, + binShell: whichSync('bash') ?? undefined, + ripgrepConfig: { command: whichSync('rg') ?? 'rg' }, + } const wrap = ( denyWithinAllow: string[], command = 'true', @@ -139,6 +147,7 @@ describe.if(isLinux)('A placeholder where no mount point can be made', () => { command, needsNetworkRestriction: false, writeConfig: { allowOnly, denyWithinAllow }, + ...HELPERS, }) const run = (command: string) => diff --git a/test/sandbox/literal-path-entries.test.ts b/test/sandbox/literal-path-entries.test.ts index 5dc79e9a4..ec32a9202 100644 --- a/test/sandbox/literal-path-entries.test.ts +++ b/test/sandbox/literal-path-entries.test.ts @@ -1321,9 +1321,9 @@ describe.if(isLinux)( it.skipIf(!CAN_RUN || process.getuid?.() === 0)( 'takes any write deny with glob characters for the path it spells when the folder cannot be looked at', async () => { - // Whether anything in the folder has the name cannot be told, so a - // pattern is also applied like a path that is not there: the name is - // kept from being created, and what it would match is not denied. + // Whether anything in the folder has the name cannot be told, nor + // what: so the folder is denied whole, wherever it lies. Its mode + // cannot be given back, so what the pattern would match is safe too. const locked = join(root, 'locked') const elsewhere = join(root, 'elsewhere') mkdirSync(locked) @@ -1377,13 +1377,12 @@ describe.if(isLinux)( const { denied, stdout } = await wrapsWith(name, elsewhere) expect(denied).toEqual([join(locked, name)]) expect(stdout).not.toContain('CREATED') - expect(stdout).toContain('WROTE') + expect(stdout).not.toContain('WROTE') expect(stdout).toContain('END') expect(existsSync(join(locked, name))).toBe(false) - // Below the working directory the folder is denied whole, because - // the scan for dangerous names could not read it either: its mode - // cannot be given back, so what the pattern would match is safe too. + // Below the working directory the scan for dangerous names, which + // could not read it either, comes to the same. const below = await wrapsWith(name, root) expect(below.denied).toEqual([join(locked, name)]) expect(below.stdout).not.toContain('CREATED') @@ -1391,12 +1390,10 @@ describe.if(isLinux)( expect(below.stdout).toContain('END') expect(existsSync(join(locked, name))).toBe(false) } - // The folder cannot be cleaned by the host while it cannot be looked - // at, for a name and for a pattern alike. - const leftByName = await leftBy('absent') - expect(await leftBy('*.pem')).toEqual( - leftByName.map(entry => (entry === 'absent' ? '*.pem' : entry)), - ) + // Nothing is made in a folder the host could not clean while it cannot + // be looked at, for a name and for a pattern alike. + expect(await leftBy('absent')).toEqual([]) + expect(await leftBy('*.pem')).toEqual([]) }, WRAP_TIMEOUT_MS, ) diff --git a/test/sandbox/macos-glob-sets.test.ts b/test/sandbox/macos-glob-sets.test.ts new file mode 100644 index 000000000..8c1c2c326 --- /dev/null +++ b/test/sandbox/macos-glob-sets.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, afterAll, beforeAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { denyGlobRegex, globToRegex } from '../../src/sandbox/glob-pattern.js' +import { isMacOS } from '../helpers/platform.js' + +/** + * globToRegex writes one string for two regex engines, JavaScript's and a + * macOS sandbox profile's. What a pattern means is glob-pattern.test.ts's; + * here it is that both engines read what is written for a set alike. + */ + +/** The characters the two disagree about, and a neighbour of each. */ +const TRICKY = [...'-,.0:=[\\^ab'] +/** Every set of one or two of them, and what only a range shows. */ +const SETS = [ + ...TRICKY, + ...TRICKY.flatMap(a => TRICKY.map(b => a + b)), + ...['--0', '+--', '+-9', 'a-c.', '0-9', 'x^-z', '[:alpha:', '$', 'cab'], + ...['\\-a', '-+.', ',.-'], +].flatMap(body => [`[${body}]`, `[!${body}]`]) + +describe('a bracket set, as it is written', () => { + it.each([ + // Neighbours as one range, the `/` of a negated set among them. + ['[!0-9]', '[^/-9]'], + ['[!.]', '[^.-/]'], + ['[!cab]', '[^/a-c]'], + ['[!$]', '[^$/]'], + // In ascending order, so that no `[` opens a POSIX element. + ['[[.]', '[.[]'], + ['[[:alpha:]', '[:[ahlp]'], + // A `-` first, and a range that starts or ends at one without it. + ['[a-]', '[-a]'], + ['[!-a]', '[^-/a]'], + ['[--0]', '[-.-0]'], + ['[+--]', '[-+-,]'], + // A `^` last, a backslash doubled, and a `-` alone without a set. + ['[a^]', '[a^]'], + ['[x^-z]', '[_-z^]'], + ['[!\\]', '[^/\\\\]'], + ['[-]', '-'], + // Beyond ASCII as written, after the rest. + ['[😀😁a]', '[a😀😁]'], + ['[!😀😁]', '[^/😀😁]'], + ])('%s is %s', (set, regex) => { + expect(globToRegex(`/d/${set}`)).toBe(`^/d/${regex}$`) + }) + + it('holds nothing the profile engine was seen to refuse or misread', () => { + const one = String.raw`(?:\\\\|[^\\\]-])` + const wellFormed = new RegExp(`^\\^?-?(?:${one}(?:-${one})?)+$`) + const bad: string[] = [] + let bodies = [''] + for (let length = 1; length <= 4; length++) { + bodies = bodies.flatMap(body => TRICKY.map(char => body + char)) + for (const glob of bodies.flatMap(b => [`[${b}]`, `[!${b}]`])) { + const regex = globToRegex(glob).slice(1, -1) + // Text, or a `-` that needs no set. + if (!regex.startsWith('[')) continue + const set = regex.slice(1, -1) + // Apart from the `-`, which has to stand first, no two neighbours of + // a negated set are written apart. + const rest = [...set.replace(/^\^-?/, '').replace(/\\\\/g, '\\')] + const codes = (beside: -1 | 1): number[] => + rest + .filter((char, i) => char !== '-' && rest[i + beside] !== '-') + .map(char => char.charCodeAt(0)) + const ends = codes(1) + if ( + !wellFormed.test(set) || + /\[[.:=]/.test(set) || + set.startsWith('^') !== /^\[[!^]/.test(glob) || + (set.startsWith('^') && + codes(-1) + .slice(1) + .some((start, i) => start <= ends[i]! + 1)) + ) { + bad.push(`${glob} is written ${regex}`) + } + } + } + expect(bad.slice(0, 10)).toEqual([]) + }) +}) + +describe.if(isMacOS)('a bracket set, as a sandbox profile reads it', () => { + const NAMES = [...new Set([...TRICKY, ...'+1]c!$z'])].map(char => `x${char}`) + const RULES = ['deny', 'allow', 'carved'] + let dir: string + + beforeAll(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'glob-sets-'))) + for (const rule of RULES) { + mkdirSync(join(dir, rule)) + for (const name of NAMES) writeFileSync(join(dir, rule, name), '') + } + }) + + afterAll(() => rmSync(dir, { recursive: true, force: true })) + + // The three places a pattern's regex goes: a deny, with its tail, an allow, + // and what is carved out of a deny. + it('as JavaScript does, in a deny, in an allow and in a carve-out', () => { + const differing: string[] = [] + for (const set of SETS) { + const regex = (rule: string, compile = globToRegex): string => + JSON.stringify(compile(join(dir, rule, `x${set}`))) + const { stdout } = spawnSync( + '/usr/bin/sandbox-exec', + [ + '-p', + `(version 1)(allow default) + (deny file-read* (regex ${regex('deny', denyGlobRegex)})) + (deny file-read* (subpath ${JSON.stringify(join(dir, 'allow'))})) + (allow file-read* (regex ${regex('allow')})) + (deny file-read* (require-all + (subpath ${JSON.stringify(join(dir, 'carved'))}) + (require-not (regex ${regex('carved')}))))`, + '/bin/sh', + '-c', + // By name: a directory denied whole lists nothing. + 'for f; do /bin/cat "$f" 2>/dev/null && echo "$f"; done', + 'sh', + ...RULES.flatMap(rule => NAMES.map(name => `${rule}/${name}`)), + ], + { cwd: dir, encoding: 'utf8', timeout: 10000 }, + ) + // A profile that does not load reads nothing. + const readable = stdout.split('\n') + const matches = new RegExp(globToRegex(`x${set}`)) + // A `-` cannot be written into a range with its neighbours, and with + // one of them a negated set is read as `[^/]` in a deny: wider. + const widened = /^\^x\[\^-.*(,|\.-)/.test(matches.source) + const wrong = RULES.flatMap(rule => + NAMES.filter( + name => + readable.includes(`${rule}/${name}`) !== + (rule === 'deny' + ? !widened && !matches.test(name) + : matches.test(name)), + ).map(name => `${rule}/${name}`), + ) + if (wrong.length > 0) { + differing.push(`${set}, ${matches.source}: ${wrong.join(' ')}`) + } + } + expect(differing).toEqual([]) + }, 120_000) +}) diff --git a/test/sandbox/proxy-only-edges.test.ts b/test/sandbox/proxy-only-edges.test.ts index f39102c93..1a94fd992 100644 --- a/test/sandbox/proxy-only-edges.test.ts +++ b/test/sandbox/proxy-only-edges.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, test } from 'bun:test' +import { closeSync, openSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' -import { startProxyOnly } from '../../src/sandbox/proxy-only.js' +import { openFdStreams, startProxyOnly } from '../../src/sandbox/proxy-only.js' import { SERVES_EMITTED_CONNECTIONS, testWithTls, } from '../helpers/emitted-connections.js' import { fakeDecider, type Frame } from '../helpers/fake-decider.js' +import { isLinux } from '../helpers/platform.js' const cleanup: Array<() => Promise | void> = [] afterEach(async () => { @@ -324,3 +326,21 @@ describe('TRACE inside a terminated tunnel', () => { }, ) }) + +describe('a write to an inherited fd that fails', () => { + // /dev/full refuses every write. Bun buffers the small chunk, so flush() + // reports it, and hands the large one straight to write(). + test.skipIf(!isLinux).each([1, 1 << 20])( + 'reaches the callback: %d byte(s)', + async size => { + const fd = openSync('/dev/full', 'w') + cleanup.push(() => closeSync(fd)) + const { output } = openFdStreams(fd) + output.on('error', () => {}) + const failure = await new Promise(resolve => + output.write(Buffer.alloc(size), resolve), + ) + expect(failure).toMatchObject({ code: 'ENOSPC' }) + }, + ) +}) diff --git a/test/sandbox/read-deny-glob-bound.test.ts b/test/sandbox/read-deny-glob-bound.test.ts new file mode 100644 index 000000000..b92ab10d3 --- /dev/null +++ b/test/sandbox/read-deny-glob-bound.test.ts @@ -0,0 +1,407 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' +// The namespace the library binds, so the code under test sees a spy on it. +import * as fs from 'fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expandReadDenyGlobLinux } from '../../src/sandbox/read-deny-glob.js' +import { + FilesystemConfigSchema, + type SandboxRuntimeConfig, +} from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { + type GlobWalkListings, + newGlobWalkBudget, + walkGlobPattern, +} from '../../src/sandbox/sandbox-utils.js' +import { isLinux, isWindows } from '../helpers/platform.js' + +const DIRECTORIES = 6 +/** What a planted tree holds: its directories, and the four files in each. */ +const ENTRIES = DIRECTORIES * 5 +let ROOT: string +let TREE: string +/** Matches the `.env` in each directory of TREE. */ +let PATTERN: string + +/** `ROOT/`: d0 to d5, a `.env` and three other files in each. */ +function plantTree(name: string): string { + for (let d = 0; d < DIRECTORIES; d++) { + fs.mkdirSync(join(ROOT, name, `d${d}`), { recursive: true }) + for (const file of ['.env', 'a', 'b', 'c']) { + fs.writeFileSync(join(ROOT, name, `d${d}`, file), '') + } + } + return join(ROOT, name) +} + +/** Has the clock move a millisecond each time it is read. */ +function tickingClock(): void { + let now = 0 + spyOn(performance, 'now').mockImplementation(() => ++now) +} + +/** Has every listing answer what `listed` makes of it. */ +function listingsThrough( + listed: (dir: string, entries: fs.Dirent[]) => unknown[], +): void { + const { readdirSync } = fs + spyOn(fs, 'readdirSync').mockImplementation((( + ...args: Parameters + ) => + listed( + String(args[0]), + readdirSync(...args) as unknown as fs.Dirent[], + )) as unknown as typeof readdirSync) +} + +/** What `run` throws or rejects with: undefined when it does neither. */ +async function refusalOf(run: () => unknown): Promise { + try { + await run() + return undefined + } catch (error) { + return error + } +} + +beforeAll(() => { + ROOT = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'deny-glob-budget-'))) + TREE = plantTree('tree') + PATTERN = join(TREE, '**/.env') +}) + +afterEach(() => { + mock.restore() +}) + +afterAll(() => { + fs.rmSync(ROOT, { recursive: true, force: true }) +}) + +describe.if(!isWindows)('the budget of a glob walk', () => { + const expand = (pattern: string, maxEntries: number): string[] => + expandReadDenyGlobLinux(pattern, [], undefined, { + budget: newGlobWalkBudget({ maxEntries }), + }) + + it('is charged for every entry read from disk, matched or not, and not for one another walk has read', () => { + const budget = newGlobWalkBudget() + const listings: GlobWalkListings = new Map() + for (const pattern of [PATTERN, join(TREE, '**/a')]) { + walkGlobPattern(pattern, { budget, listings }) + expect(budget.entries).toBe(ENTRIES) + } + // Not handed what they read, so it reads the tree again. + walkGlobPattern(PATTERN, { budget }) + expect(budget.entries).toBe(2 * ENTRIES) + }) + + it('throws at the entry past maxEntries, and says where it was', async () => { + // Exactly enough is enough. + expect(expand(PATTERN, ENTRIES)).toHaveLength(DIRECTORIES) + + expect(await refusalOf(() => expand(PATTERN, ENTRIES - 1))).toMatchObject({ + name: 'GlobWalkBudgetError', + message: expect.stringContaining(PATTERN), + pattern: PATTERN, + directory: expect.stringContaining(TREE + '/'), + exhausted: 'entries', + entries: ENTRIES, + elapsedMs: expect.any(Number), + maxEntries: ENTRIES - 1, + timeoutMs: 60_000, + }) + }) + + it('is charged for what links lead to, one after another, named where it really is', async () => { + // linked/out -> ../hop and hop/on -> ../tree: an entry each, and the tree. + // A command allowed to write there can make the way as long as it likes. + fs.mkdirSync(join(ROOT, 'linked')) + fs.mkdirSync(join(ROOT, 'hop')) + fs.symlinkSync(join('..', 'hop'), join(ROOT, 'linked', 'out')) + fs.symlinkSync(join('..', 'tree'), join(ROOT, 'hop', 'on')) + const pattern = join(ROOT, 'linked', '**/.env') + + expect(expand(pattern, 2 + ENTRIES)).toEqual(expand(PATTERN, ENTRIES)) + expect(await refusalOf(() => expand(pattern, 1 + ENTRIES))).toMatchObject({ + pattern, + directory: expect.stringContaining(TREE + '/'), + }) + }) + + // The budget is made at 1 ms, so the reading at n ms finds n - 1 gone: at 2 + // the tree is about to be listed, from 3 to 8 its entries are met, at 9 and + // from 10 to 13 the same for a directory in it, and at 14 for the next. + it.each([ + ['before the first listing', 1, false, /tree$/, 0], + ['at an entry, not at every so many', 4, false, /tree$/, 3], + ['at an entry another pattern has read', 4, true, /tree$/, 0], + ['after a step, before the next listing', 13, false, /tree\/d\d$/, 6 + 4], + ])( + 'throws once the time is up: %s', + async (_when, timeoutMs, read, directory, entries) => { + const listings: GlobWalkListings = new Map() + if (read) walkGlobPattern(join(TREE, '**/a'), { listings }) + tickingClock() + const budget = newGlobWalkBudget({ timeoutMs }) + + expect( + await refusalOf(() => walkGlobPattern(PATTERN, { budget, listings })), + ).toMatchObject({ + exhausted: 'time', + directory: expect.stringMatching(directory), + entries, + }) + }, + ) + + it('throws before an entry whose type has to be asked for, and not as a directory that could not be listed', async () => { + // Listed the way some NFS and FUSE mounts list: names, and no types. + const untyped: ProxyHandler = { + get: ({ name }, key) => (key === 'name' ? name : () => false), + } + const listed: string[] = [] + listingsThrough((dir, entries) => { + listed.push(dir) + return entries.map(e => new Proxy(e, untyped)) + }) + const asked = spyOn(fs, 'lstatSync') + tickingClock() + const budget = newGlobWalkBudget({ timeoutMs: 4 }) + + expect( + await refusalOf(() => walkGlobPattern(PATTERN, { budget })), + ).toMatchObject({ exhausted: 'time', directory: TREE, entries: 0 }) + expect(asked).toHaveBeenCalledTimes(2) + + // Nor as a real path that could not be read, which is asked for again by + // a shorter name that led to it. + fs.mkdirSync(join(ROOT, 'v')) + fs.symlinkSync(TREE, join(ROOT, 'v', 'l')) + listed.length = 0 + expect( + await refusalOf(() => + expandReadDenyGlobLinux(join(ROOT, 'v', '**/.env'), [], undefined, { + budget: newGlobWalkBudget({ timeoutMs: 6 }), + }), + ), + ).toMatchObject({ exhausted: 'time', directory: TREE }) + expect(listed).toEqual([join(ROOT, 'v'), TREE]) + }) +}) + +describe.if(isLinux)('the budget of a read configuration', () => { + function configOf( + denyReadGlobBudget: object | undefined, + lists: { denyRead?: string[]; allowRead?: string[] } = {}, + credentials?: SandboxRuntimeConfig['credentials'], + ): SandboxRuntimeConfig { + return { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + ...lists, + denyReadGlobBudget, + }, + credentials, + } + } + + afterAll(() => SandboxManager.reset()) + + describe.each([ + ['getFsReadConfig()', () => SandboxManager.getFsReadConfig()], + ['wrapWithSandbox()', () => SandboxManager.wrapWithSandbox('true')], + ['wrapWithSandboxArgv()', () => SandboxManager.wrapWithSandboxArgv('true')], + ])('worked out by %s', (_way, workOut) => { + /** What refuses it, if anything does, in a session of that configuration. */ + const refusalUnder = ( + ...config: Parameters + ): Promise => { + SandboxManager.updateConfig(configOf(...config)) + return refusalOf(workOut) + } + + it.each([ + [{ maxEntries: ENTRIES - 1 }, 'entries'], + [{ timeoutMs: 1 }, 'time'], + ])('is refused past %o', async (limits, exhausted) => { + const held = { maxEntries: 20_000_000, timeoutMs: 60_000, ...limits } + tickingClock() + + const refusal = await refusalUnder(limits, { denyRead: [PATTERN] }) + + expect(refusal).toMatchObject({ + name: 'LinuxSandboxProfileError', + code: 'deny_glob_too_large', + cause: { + name: 'GlobWalkBudgetError', + pattern: PATTERN, + exhausted, + ...held, + }, + }) + for (const part of [ + `"${PATTERN}"`, + `the ${exhausted} ran out`, + `of ${held.maxEntries} directory entries`, + `of ${held.timeoutMs} ms`, + `while listing ${TREE}`, + 'narrow the pattern', + 'or raise filesystem.denyReadGlobBudget', + ]) { + expect(String(refusal)).toContain(part) + } + }) + + it.each<[string, string, (pattern: string) => [object, object?]]>([ + ['allowRead', 'tree', p => [{ allowRead: [p] }]], + [ + 'credentials.files', + 'tree', + p => [{}, { files: [{ path: p, mode: 'deny' }] }], + ], + // Walked beneath an anchor: the folder, brackets and all, is a name. + ['denyRead', 'pk[g]s', p => [{ denyRead: [p] }]], + ])( + 'holds a pattern of %s, in %s, to it', + async (_list, folder, listing) => { + const pattern = join(plantTree(folder), '**/.env') + + expect( + await refusalUnder({ maxEntries: 1000 }, ...listing(pattern)), + ).toBeUndefined() + expect( + await refusalUnder({ maxEntries: ENTRIES - 1 }, ...listing(pattern)), + ).toMatchObject({ code: 'deny_glob_too_large', cause: { pattern } }) + }, + ) + + it('has one for all its patterns, charged once for a tree that two denyRead patterns go through', async () => { + const limits = { maxEntries: ENTRIES } + const other = join(plantTree('other'), '**/.env') + + expect( + await refusalUnder(limits, { denyRead: [PATTERN, join(TREE, '**/a')] }), + ).toBeUndefined() + for (const lists of [ + { denyRead: [PATTERN, other] }, + { allowRead: [PATTERN, other] }, + { allowRead: [PATTERN], denyRead: [other] }, + ]) { + // The pattern that was being expanded when the budget ran out. + expect(await refusalUnder(limits, lists)).toMatchObject({ + cause: { pattern: other, entries: ENTRIES + 1 }, + }) + } + }) + + // NaN would never be reached, and a misspelt key would leave the default. + it.each([{ maxEntries: NaN }, { timeoutMs: '1000' }, { maxEntires: 1 }])( + 'fails on limits that no schema has seen: %o', + async limits => { + expect(await refusalUnder(limits)).toMatchObject({ + message: expect.stringMatching(/filesystem[^]*denyReadGlobBudget/), + }) + }, + ) + }) + + it('takes the limits of one wrap over those of the session, initialized or updated, one by one', async () => { + const lists = { denyRead: [PATTERN] } + const refusalWith = (limits?: object): Promise => { + const own = configOf(limits, lists) + return refusalOf(() => + SandboxManager.wrapWithSandbox('true', undefined, own), + ) + } + await SandboxManager.reset() + await SandboxManager.initialize( + configOf({ maxEntries: ENTRIES - 1, timeoutMs: 1234 }, lists), + ) + + expect(await refusalWith()).toMatchObject({ + cause: { maxEntries: ENTRIES - 1, timeoutMs: 1234 }, + }) + expect( + await refusalWith({ maxEntries: ENTRIES - 2, timeoutMs: undefined }), + ).toMatchObject({ cause: { maxEntries: ENTRIES - 2, timeoutMs: 1234 } }) + expect(await refusalWith({ maxEntries: ENTRIES })).toBeUndefined() + + SandboxManager.updateConfig(configOf({ maxEntries: ENTRIES }, lists)) + expect(await refusalWith()).toBeUndefined() + expect(await refusalWith({ maxEntries: ENTRIES - 1 })).toMatchObject({ + cause: { maxEntries: ENTRIES - 1, timeoutMs: 60_000 }, + }) + }) + + // The allowRead walk lists every directory, and then the denyRead walk does. + it.each([ + ['allowRead', 2], + ['denyRead', DIRECTORIES + 1 + 2], + ])( + 'starts over under the budget of a configuration that arrives during the %s walk, with nothing spent', + async (_walk, arrivesAt) => { + const lists = { allowRead: [join(TREE, '**/a')], denyRead: [PATTERN] } + const refusalUnderLate = (maxEntries: number): Promise => { + mock.restore() + SandboxManager.updateConfig(configOf(undefined, lists)) + let listed = 0 + listingsThrough((dir, entries) => { + if (dir.startsWith(TREE) && ++listed === arrivesAt) { + SandboxManager.updateConfig(configOf({ maxEntries }, lists)) + } + return entries + }) + return refusalOf(() => SandboxManager.wrapWithSandbox('true')) + } + + expect(await refusalUnderLate(ENTRIES - 1)).toMatchObject({ + cause: { maxEntries: ENTRIES - 1 }, + }) + // What the two walks of one attempt read, and no more. + expect(await refusalUnderLate(2 * ENTRIES)).toBeUndefined() + }, + ) +}) + +describe('filesystem.denyReadGlobBudget in the schema', () => { + it.each([ + [true, undefined], + [true, { maxEntries: 1 }], + [true, { timeoutMs: 120_000 }], + [true, { maxEntries: 50_000_000, timeoutMs: 1 }], + [false, { maxEntries: 0 }], + [false, { maxEntries: 1.5 }], + [false, { maxEntries: '1000' }], + [false, { maxEntries: Infinity }], + [false, { maxEntires: 1 }], + [false, { timeoutMs: 0 }], + [false, { timeoutMs: 0.5 }], + [false, 1000], + ])( + 'takes positive integers, either or both, and nothing else: %p for %o', + (taken, budget) => { + expect( + FilesystemConfigSchema.safeParse({ + denyRead: [], + allowWrite: [], + denyWrite: [], + denyReadGlobBudget: budget, + }).success, + ).toBe(taken) + }, + ) +}) diff --git a/test/sandbox/symlinked-deny-paths.test.ts b/test/sandbox/symlinked-deny-paths.test.ts index b6ba7b03e..4ee1b8548 100644 --- a/test/sandbox/symlinked-deny-paths.test.ts +++ b/test/sandbox/symlinked-deny-paths.test.ts @@ -269,6 +269,10 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { const shut = join(PROJ, 'shut') expect(countMounts(result, '--ro-bind', shut, shut)).toBe(1) expect(result).not.toContain(`/dev/null ${join(PROJ, 'name')}`) + // Made a link, the missing name would lead to another `shut`. + expect( + countMounts(result, '--ro-bind', '/dev/null', join(PROJ, 'gone')), + ).toBe(target.startsWith('gone') ? 1 : 0) }, ) @@ -281,7 +285,8 @@ describe.if(isLinux)('Symlinked deny paths (resolve-before-mask)', () => { const result = await wrap([join(PROJ, 'name')]) - expect(result).not.toContain(shut) + // Among the mounts: the helper is told what the link it holds says. + expect(result.split(' -- ')[0]).not.toContain(shut) expect(result).not.toContain(`/dev/null ${join(PROJ, 'name')}`) }, ) diff --git a/test/sandbox/winsrt.test.ts b/test/sandbox/winsrt.test.ts index ad1cd4d1e..c9551eba7 100644 --- a/test/sandbox/winsrt.test.ts +++ b/test/sandbox/winsrt.test.ts @@ -18,12 +18,13 @@ import { mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs' import { homedir, tmpdir } from 'node:os' -import { dirname, isAbsolute, join } from 'node:path' +import { dirname, isAbsolute, join, relative, sep } from 'node:path' import * as net from 'node:net' import { createServer, type Server } from 'node:net' import type { AddressInfo } from 'node:net' @@ -83,6 +84,7 @@ import { parseWindowsBinShell, rankWindowsFsAccess, resolveSrtWin, + windowsMandatoryDenyPaths, buildGitConfigEnv, SRT_WIN_DISPATCH_ARG1, DEFAULT_WINDOWS_PROXY_PORT_RANGE, @@ -1444,6 +1446,163 @@ describe('rankWindowsFsAccess (pure, all platforms)', () => { }) }) +describe('windowsMandatoryDenyPaths (pure, all platforms)', () => { + let root: string + const at = (rel: string): string => join(root, ...rel.split('/')) + const plant = (files: Record | string[]): void => { + for (const [rel, content] of Array.isArray(files) + ? files.map(rel => [rel, '']) + : Object.entries(files)) { + mkdirSync(dirname(at(rel)), { recursive: true }) + writeFileSync(at(rel), content) + } + } + /** What is denied for a command run in `cwd`, named from `root`. */ + const denied = ( + cwd = '.', + opts: { maxDepth?: number; allowGitConfig?: boolean } = {}, + writeRoots = ['.'], + ): string[] => + windowsMandatoryDenyPaths(at(cwd), writeRoots.map(at), { + maxDepth: 3, + allowGitConfig: false, + ...opts, + }) + .map(p => relative(root, p).split(sep).join('/')) + .sort() + + beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'srt-mandatory-'))) + }) + afterEach(() => rmSync(root, { recursive: true, force: true })) + + it('the names the other platforms deny, in whatever case, where they are there', () => { + plant([ + '.bashrc', + '.MCP.json', + '.git/HEAD', + '.git/config', + '.git/hooks/pre-commit', + '.vscode/settings.json', + '.claude/settings.json', + '.claude/commands/x.md', + 'sub/.gitmodules', + 'sub/.IDEA/workspace.xml', + 'sub/.Git/Hooks/pre-push', + 'src/bashrc', + 'node_modules/pkg/.bashrc', + '.git/modules/x/.bashrc', + ]) + const all = [ + '.MCP.json', + '.bashrc', + '.claude/commands', + '.git/config', + '.git/hooks', + '.vscode', + 'sub/.Git/Hooks', + 'sub/.IDEA', + 'sub/.gitmodules', + ] + expect(denied()).toEqual(all) + expect(denied('.', { allowGitConfig: true })).toEqual( + all.filter(p => p !== '.git/config'), + ) + expect(denied('sub')).toEqual(all.filter(p => p.startsWith('sub/'))) + }) + + it('each name down to maxDepth levels below the working directory', () => { + plant(['.bashrc', 'a/.bashrc', 'a/b/.git/hooks/x', 'a/b/c/.bashrc']) + expect(denied('.', { maxDepth: 1 })).toEqual(['.bashrc']) + expect(denied('.', { maxDepth: 2 })).toEqual(['.bashrc', 'a/.bashrc']) + expect(denied()).toEqual(['.bashrc', 'a/.bashrc', 'a/b/.git/hooks']) + }) + + it('only what a command could write', () => { + plant(['.bashrc', 'out/.bashrc', 'outer/.bashrc']) + expect(denied('.', {}, ['out'])).toEqual(['out/.bashrc']) + expect(denied('.', {}, [])).toEqual([]) + }) + + it('a `.git` file, and the same names where it leads', () => { + plant({ + 'wt/.git': 'gitdir: ../main/.git/worktrees/wt\n', + 'main/.git/worktrees/wt/commondir': '../..\r\n', + 'main/.git/worktrees/wt/config': '', + 'main/.git/hooks/pre-commit': '', + 'main/.git/config': '', + 'main/sub/.git': `gitdir: ${at('main/.git/modules/sub')}`, + 'main/.git/modules/sub/hooks/pre-commit': '', + }) + expect(denied('wt')).toEqual([ + 'main/.git/config', + 'main/.git/hooks', + 'main/.git/worktrees/wt/config', + 'wt/.git', + ]) + expect(denied('main/sub')).toEqual([ + 'main/.git/modules/sub/hooks', + 'main/sub/.git', + ]) + }) + + it('a link is passed over, at a name and on the way to one', () => { + plant({ + 'real/.bashrc': '', + 'real/hooks/x': '', + 'wt/.git': 'gitdir: ../linked/admin', + 'wt2/.git': 'gitdir: ../real/admin', + 'real/admin/hooks/x': '', + 'real/pointer': '..', + }) + for (const link of ['.vscode', '.git', 'linked']) { + symlinkSync(at('real'), at(link), 'junction') + } + symlinkSync(at('real/pointer'), at('real/admin/commondir'), 'junction') + const stats = spyOn(fs, 'lstatSync') + const found = denied() + const looked = stats.mock.calls.join() + stats.mockRestore() + expect(found).toEqual([ + 'real/.bashrc', + 'real/admin/hooks', + 'wt/.git', + 'wt2/.git', + ]) + // From the top down: nothing is looked at through the link. + expect(looked).not.toContain(at('linked/admin')) + // The working directory is the host's word, and is resolved. + expect(denied('linked')).toEqual(['real/.bashrc']) + }) + + it.each([ + ['out of every write root', () => `gitdir: ${at('outside')}`], + ['to a share', () => 'gitdir: //server/share/repo.git'], + ['to a device', () => 'gitdir: \\\\?\\GLOBALROOT\\Device\\x'], + ['nowhere', () => 'not a pointer'], + ['somewhere after 4 KiB', () => `gitdir: ..\n${'#'.repeat(4096)}`], + ])( + 'a `.git` file that leads %s is denied alone, and nothing there is read', + (_where, content) => { + plant({ + 'in/wt/.git': content(), + 'in/hooks/x': '', + 'outside/hooks/x': '', + }) + const looked = spyOn(fs, 'readdirSync') + const stats = spyOn(fs, 'lstatSync') + try { + expect(denied('in/wt', {}, ['in'])).toEqual(['in/wt/.git']) + expect(looked.mock.calls.map(([dir]) => dir)).toEqual([at('in/wt')]) + expect(stats.mock.calls.join()).not.toMatch(/server|GLOBALROOT/) + } finally { + looked.mockRestore() + stats.mockRestore() + } + }, + ) +}) + // Run once per child-token mode; not on Windows, where the L-rows make the calls. describe.if(!isWindows)('what srt-win is handed (platform spy)', () => { const spies: Array> = [] @@ -1627,6 +1786,128 @@ describe.if(!isWindows)('what srt-win is handed (platform spy)', () => { return argv.slice(0, argv.indexOf('--')).includes('--allow-loopback') } + describe('the mandatory write denies', () => { + const cwd = process.cwd() + let hooks: string + let mine: string + let config: string + /** What one command run in `root` has stamped before it is wrapped. */ + const stampedFor = async ( + customConfig?: Partial, + ): Promise => { + stamped = undefined + const args = [undefined, customConfig, undefined, root] as const + const { argv } = await SandboxManager.wrapWithSandboxArgv('rem', ...args) + expect(argv).not.toContain('--deny-write') + return writeDenies() + } + // Apart, or the type checker takes `stamped` for undefined still. + const writeDenies = (): readonly string[] => stamped?.denyWrite ?? [] + + beforeEach(() => { + hooks = join(root, '.git', 'hooks') + mine = join(hooks, 'mine') + config = join(root, '.git', 'config') + mkdirSync(mine, { recursive: true }) + writeFileSync(config, '') + }) + afterEach(() => process.chdir(cwd)) + + it("initialize() stamps the working directory's with the configured ones, and they rank alike", async () => { + process.chdir(root) + const share = '\\\\server\\share\\x' + const stats = spyOn(fs, 'lstatSync') + spies.push(stats) + await SandboxManager.initialize( + configOf({ allowWrite: [root, mine], denyWrite: [d, share] }), + ) + expect(stamped?.denyWrite).toEqual([d, share, config, hooks]) + expect(stats.mock.calls.join()).not.toContain('server') + expect(granted?.write).toEqual([root]) + expect(await stampedFor()).toEqual([]) + }) + + it('a command stamps what is new, or another object than the one stamped, once', async () => { + await SandboxManager.initialize(configOf({ allowWrite: [root, mine] })) + expect(stamped).toBeUndefined() + // With the grant the session holds beneath one, which it outranks. + expect(await stampedFor()).toEqual([config, hooks, mine]) + expect(await stampedFor()).toEqual([]) + + writeFileSync(join(root, '.mcp.json'), '{}') + expect(await stampedFor()).toEqual([join(root, '.mcp.json')]) + + // As git writes it: a new file, renamed over the old one. + writeFileSync(`${config}.lock`, 'new') + renameSync(`${config}.lock`, config) + expect(await stampedFor()).toEqual([config]) + expect(await stampedFor()).toEqual([]) + + // The next session starts from what it stamps itself. + await SandboxManager.reset() + await SandboxManager.initialize(configOf({ allowWrite: [root] })) + expect(await stampedFor()).toHaveLength(3) + }) + + it('a command that could not have them stamped is not wrapped, and the next tries again', async () => { + await SandboxManager.initialize(configOf({ allowWrite: [root] })) + spyOn(winUtils, 'grantWindowsAclPersistent').mockRejectedValueOnce( + new WindowsSandboxError('acl_grant_failed', 'refused'), + ) + expect(await stampedFor().catch((e: Error) => e.message)).toBe('refused') + expect(await stampedFor()).toEqual([config, hooks]) + }) + + it.each([ + ['initialize()', true], + ['a command', false], + ])( + '%s reads allowGitConfig and mandatoryDenySearchDepth', + async (_who, atInitialize) => { + writeFileSync(join(d, '.bashrc'), '') + if (atInitialize) process.chdir(root) + await SandboxManager.initialize({ + ...configOf({ allowWrite: [root], allowGitConfig: true }), + mandatoryDenySearchDepth: 1, + }) + expect(atInitialize ? stamped?.denyWrite : await stampedFor()).toEqual([ + hooks, + ]) + }, + ) + + it("filesystem.disabled, the command's own or the session's, stamps none", async () => { + const session = configOf({ allowWrite: [root] }) + const disabled = { filesystem: { ...session.filesystem, disabled: true } } + await SandboxManager.initialize(session) + expect(await stampedFor(disabled)).toEqual([]) + await SandboxManager.reset() + await SandboxManager.initialize({ ...session, ...disabled }) + expect(await stampedFor()).toEqual([]) + }) + + it('the mxc backend, where a write deny takes reads away too, is handed none', async () => { + process.chdir(root) + const verify = spyOn(mxcUtils, 'verifyMxcBackend').mockReturnValue() + const spawn = spyOn(mxcUtils, 'spawnMxcRequest') + spies.push( + verify, + spawn.mockResolvedValue({} as MxcProcessHandle), + spyOn(mxcUtils, 'isMxcSdkInstalled').mockReturnValue(true), + spyOn(mxcUtils, 'loadMxcSdk').mockResolvedValue({} as mxcUtils.MxcSdk), + ) + const base = configOf({ allowWrite: [root], denyWrite: [d] }) + await SandboxManager.initialize({ + ...base, + windows: { ...base.windows, backend: 'mxc' }, + }) + await SandboxManager.spawnSandboxed!('rem', { cwd: root }) + for (const [[, request]] of [verify.mock.calls, spawn.mock.calls]) { + expect(request.filesystem.deniedPaths).toEqual([d]) + } + }) + }) + it('allowLocalBinding: the session mode reaches the fence probe and every exec', async () => { const verify = spyOn(winUtils, 'verifyWindowsWfpEgress') spies.push(verify) @@ -6009,6 +6290,149 @@ describe.if(isWindows)( rmSync(hScratch, { recursive: true, force: true }) }, 30_000) + // ── M-rows: the mandatory write denies ── + describe('M-rows: the mandatory write denies', () => { + let root: string + const at = (rel: string): string => join(root, ...rel.split('/')) + const ORIGINAL = 'ORIGINAL' + + beforeEach(async () => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'srt-m-'))) + for (const rel of [ + '.bashrc', + '.git/config', + '.git/hooks/pre-commit', + '.git/HEAD', + 'sub/repo/.git/hooks/pre-commit', + 'plain.txt', + ]) { + mkdirSync(dirname(at(rel)), { recursive: true }) + writeFileSync(at(rel), ORIGINAL) + } + // One kept root inside another: what holds the outer one could + // move the inner one aside. + await SandboxManager.initialize( + createFsTestConfig({ allowWrite: [root, at('sub/repo')] }), + ) + }) + + /** Ends the session and has both roots forgotten. */ + async function forget(): Promise { + await SandboxManager.reset() + for (const kept of [at('sub/repo'), root]) { + spawnSync(exe, ['acl', 'forget', kept], { encoding: 'utf8' }) + } + } + + afterEach(async () => { + await forget() + rmSync(root, { recursive: true, force: true }) + }) + + /** Runs `command` in `root`, sandboxed. */ + async function run(id: string, command: string): Promise { + const { argv, env } = await SandboxManager.wrapWithSandboxArgv( + `echo RAN & ${command}`, + undefined, + undefined, + undefined, + root, + ) + const r = await spawnAsync(argv[0], argv.slice(1), { + env, + cwd: root, + timeout: 60_000, + }) + console.error( + `[winsrt ${id}] exit=${r.status} stdout=${JSON.stringify(r.stdout)} ` + + `stderr=${JSON.stringify(r.stderr)}`, + ) + expect(r.stdout).toContain('RAN') + } + const contentOf = (rel: string): string | undefined => + existsSync(at(rel)) ? readFileSync(at(rel), 'utf8').trim() : undefined + + it('M1: what is there is not written, replaced, removed or moved aside; the rest of the root is', async () => { + await run( + 'M1', + [ + // First, so that what follows would show it. + 'icacls .bashrc /reset', + 'icacls .git\\hooks /reset /t', + 'takeown /f .git\\config', + 'icacls .git\\config /reset', + 'echo W>.bashrc', + 'echo W>.git\\config', + 'echo W>.git\\hooks\\pre-commit', + 'echo W>.git\\hooks\\post-commit', + 'del /q .bashrc .git\\config', + 'rmdir /s /q .git\\hooks', + 'ren .git\\hooks aside', + 'ren .git aside', + 'ren sub\\repo aside', + 'ren sub aside', + 'echo W>plain.txt', + 'echo W>.git\\HEAD', + 'echo W>sub\\repo\\new.txt', + ].join(' & '), + ) + for (const rel of [ + '.bashrc', + '.git/config', + '.git/hooks/pre-commit', + 'sub/repo/.git/hooks/pre-commit', + ]) { + expect([rel, contentOf(rel)]).toEqual([rel, ORIGINAL]) + } + expect(contentOf('.git/hooks/post-commit')).toBeUndefined() + for (const rel of ['plain.txt', '.git/HEAD', 'sub/repo/new.txt']) { + expect([rel, contentOf(rel)]).toEqual([rel, 'W']) + } + // Nothing is made for a name that is not there. + expect(existsSync(at('.mcp.json'))).toBe(false) + expect(existsSync(at('.vscode'))).toBe(false) + }, 120_000) + + it('M2: what one command makes is denied to the next, and so is what the host puts in place of a denied file', async () => { + await run('M2a', 'mkdir made\\.git\\hooks & echo W>.mcp.json') + expect(contentOf('.mcp.json')).toBe('W') + // A deny put on what this leads to would close the root. + symlinkSync(root, at('.vscode'), 'junction') + writeFileSync(at('.git/config.lock'), 'REPLACED') + renameSync(at('.git/config.lock'), at('.git/config')) + + await run( + 'M2b', + // The sandbox account owns the first two. + 'icacls made\\.git\\hooks /reset /t & icacls .mcp.json /reset & ' + + 'echo X>made\\.git\\hooks\\pre-commit & echo X>.mcp.json & echo X>.git\\config & ' + + 'echo X>plain.txt', + ) + expect(contentOf('made/.git/hooks/pre-commit')).toBeUndefined() + expect(contentOf('.mcp.json')).toBe('W') + expect(contentOf('.git/config')).toBe('REPLACED') + expect(contentOf('plain.txt')).toBe('X') + }, 120_000) + + it('M3: the host goes on writing all of it, and `acl forget` takes all of it off', async () => { + await run('M3', 'rem') + writeFileSync(at('.git/hooks/pre-commit'), 'HOST') + writeFileSync(at('.bashrc'), 'HOST') + const aclOf = (rel: string): string => + spawnSync('icacls', [at(rel)], { encoding: 'utf8' }).stdout + const kept = ['.git', 'sub', 'sub/repo', 'sub/repo/.git'] + for (const rel of kept) { + expect(aclOf(rel)).toMatch(/:\(DENY\)\((D|DE)\)/) + } + await forget() + for (const rel of [...kept, '.', '.bashrc', '.git/hooks']) { + expect(aclOf(rel)).not.toContain('(DENY)') + } + renameSync(at('sub'), at('moved')) + expect(contentOf('moved/repo/.git/hooks/pre-commit')).toBe(ORIGINAL) + }, 120_000) + }) + // ── L-rows: how the lists rank against each other ── // A tree per row: d\own.txt, d\sub\in.txt, beside\b.txt, and to-d, a // junction to d. Each row logs what it ran, so a job log tells a row diff --git a/test/sandbox/write-deny-through-links.test.ts b/test/sandbox/write-deny-through-links.test.ts new file mode 100644 index 000000000..fff4a4f4e --- /dev/null +++ b/test/sandbox/write-deny-through-links.test.ts @@ -0,0 +1,682 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + mock, + spyOn, +} from 'bun:test' +import * as fs from 'fs' +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { homedir, tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import type { LinuxUnheldLink } from '../../src/index.js' +import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { SandboxRuntimeConfigSchema } from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { followLinks } from '../../src/sandbox/sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' + +/** + * A deny on a path with a symbolic link on the way to it, the last name + * included, covers what the links lead to and keeps the links where they are. + * Everything beside them stays as writable as it was. + * + * Every link here is made on the host before the command is wrapped. + */ + +const CWD = process.cwd() +// Where the temp dir really is: macOS names it through a link. +const BASE = join(fs.realpathSync(tmpdir()), `write-deny-links-${process.pid}`) +const ROOT = join(BASE, 'root') // the one write root +const OUTSIDE = join(BASE, 'outside') // beside it, under no write root +const HOLDER = join(ROOT, 'holder') +const inRoot = (names: string[]): string[] => names.map(n => join(ROOT, n)) + +/** Link name in ROOT -> what it says. A deny leads through every one. */ +const LINKS = { + name: 'conf/x', + 'holder/commands': '../shared/commands', + dangling: 'later', + way: 'real', + here: '.', + first: 'middle', + middle: 'conf/y', + up: 'in/to/../../real/secret', + past: 'lost/../gone/../name', + deep: 'void/deeper/../../real/secret', + far: 'holder/blank', + 'holder/blank': '/dev/null', + out: '/dev/stdout', +} +type Link = keyof typeof LINKS +const said = (names: Link[]): string[][] => names.map(n => [n, LINKS[n]]) +/** + * A name in ROOT, the links on the way to it and where they lead; then the + * directories the way goes up from, and the names that are not there and that + * it goes up from all the same. + */ +const WAYS: Array<[string, Link[], string, string[], string[]]> = [ + ['name', ['name'], 'conf/x', [], []], + ['holder/commands', ['holder/commands'], 'shared/commands', ['holder'], []], + ['dangling', ['dangling'], 'later', [], []], + ['way/secret', ['way'], 'real/secret', [], []], + ['way/absent', ['way'], 'real/absent', [], []], + // A link is named where it lies, whatever it was reached through. + ['here/name', ['here', 'name'], 'conf/x', [], []], + ['first', ['first', 'middle'], 'conf/y', [], []], + ['up', ['up'], 'real/secret', ['in/to', 'in'], []], + ['past', ['past', 'name'], 'conf/x', [], ['lost', 'gone']], + // Further on among names that are not there: it ends at the first. + ['deep', ['deep'], 'void', [], []], +] +const DENIED = inRoot([...WAYS.map(([name]) => name), 'far', 'out']) +const FILES = ['conf/x', 'conf/y', 'conf/z', 'real/secret', 'beside'] +const ABSENT = ['later', 'real/absent', 'shared/commands/new', 'lost', 'gone'] +/** EBUSY, as glibc words it ("Device or resource busy") or musl. */ +const BUSY = /resource busy/i +/** No mode keeps uid 0 from looking. */ +const modesCount = process.getuid?.() !== 0 +const namespaces = isLinux && bwrapCanNamespace() + +beforeEach(() => { + if (isWindows) return + for (const dir of ['conf', 'shared/commands', 'real', 'holder', 'in/to']) { + fs.mkdirSync(join(ROOT, dir), { recursive: true }) + } + fs.mkdirSync(OUTSIDE) + for (const file of FILES) fs.writeFileSync(join(ROOT, file), 'original') + for (const [link, says] of Object.entries(LINKS)) { + fs.symlinkSync(says, join(ROOT, link)) + } +}) + +afterEach(async () => { + if (isWindows) return + mock.restore() + process.chdir(CWD) + // Removes what bubblewrap made to mount on, and empties the list of links. + await SandboxManager.reset() + fs.chmodSync(HOLDER, 0o755) + fs.rmSync(BASE, { recursive: true, force: true }) +}) + +/** `command`, run by a shell: unless told otherwise under no write root, so + * that nothing of the working directory's is protected or made. */ +const run = (command: string, cwd = OUTSIDE): SpawnSyncReturns => + spawnSync(command, { shell: true, encoding: 'utf8', cwd, timeout: 30000 }) + +type Options = Parameters[0] & + Parameters[0] +type Rest = Partial + +/** `denyWithinAllow` denied in ROOT, the write root beside two devices. */ +function denying(denyWithinAllow: string[], rest?: Rest): Options { + const allowOnly = [ROOT, '/dev/null', '/dev/stdout'] + return { + command: 'echo started', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow }, + ...rest, + } +} + +describe.if(!isWindows)('the links on the way to a path', () => { + /** What is said of `p`, a name in ROOT, with the names in ROOT again. */ + const followed = (p: string): object => { + const { links, left, ...rest } = followLinks(`${ROOT}/${p}`) + const told = JSON.stringify({ links: [...links], left: [...left], ...rest }) + return JSON.parse(told.split(`${ROOT}/`).join('')) as object + } + const NONE = { left: [], passed: [] } + + it.each<(typeof WAYS)[number]>([ + ...WAYS, + ['beside', [], 'beside', [], []], + ['absent/name', [], 'absent/name', [], []], + ['beside/name', [], 'beside/name', [], []], + ['way/absent/deeper', ['way'], 'real/absent/deeper', [], []], + // Folded in text, as everywhere a configured name is read. + ['./way/./secret/', ['way'], 'real/secret', [], []], + ['holder/commands/../name', [], 'holder/name', [], []], + ])('%s: %p, ending at %s', (p, links, end, left, passed) => { + expect(followed(p)).toEqual({ links: said(links), left, passed, end }) + }) + + it.each([ + ['longer than a name can be', 'a'.repeat(300)], + ['with a NUL byte', 'nul\0byte'], + ])('a name %s: nothing can be there, so it has looked', (_what, name) => { + expect(followed(`way/${name}`)).toEqual({ links: said(['way']), ...NONE }) + }) + + // Not every file system takes such a target. + it.if(isLinux)('has no end past a target that is no text', () => { + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'raw')) + expect(followed('raw/file')).toEqual({ links: [['raw', '']], ...NONE }) + }) + + it('follows 40 links, and has no end past them, where the kernel gives up too', () => { + for (let i = 1; i <= 41; i++) { + fs.symlinkSync(i > 1 ? `hop-${i - 1}` : 'beside', join(ROOT, `hop-${i}`)) + } + expect(followed('hop-40')).toMatchObject({ end: 'beside' }) + expect(Object.keys(followed('hop-41'))).toEqual(['links', 'left', 'passed']) + expect(() => fs.readFileSync(join(ROOT, 'hop-41'))).toThrow('ELOOP') + }) + + it.if(modesCount)('says which directory it could not look in', () => { + fs.chmodSync(HOLDER, 0) + fs.symlinkSync('holder/commands/../made', join(ROOT, 'via')) + expect(followed('via')).toMatchObject({ ...NONE, unsearched: 'holder' }) + }) + + const failing = (at?: fs.PathLike): never => { + throw Object.assign(new Error('EIO'), { code: 'EIO', path: at }) + } + + it('looks again where a look fails', () => { + spyOn(fs, 'readlinkSync').mockImplementationOnce(failing) + expect(followed('name')).toMatchObject({ end: 'conf/x' }) + }) + + it('where that fails too: the directory of what it looked at', () => { + spyOn(fs, 'readlinkSync').mockImplementation(failing) + expect(followed('way/secret')).toMatchObject({ unsearched: ROOT }) + }) + + it('and does not say where: the directory of the path as written', () => { + spyOn(fs, 'readlinkSync').mockImplementation(() => failing()) + expect(followed('way/secret')).toMatchObject({ unsearched: 'way' }) + }) + + it('in the root: no directory, which would stand in for everything', () => { + spyOn(fs, 'lstatSync').mockImplementation(failing) + expect(followed('name')).toEqual({ links: [], ...NONE }) + }) +}) + +describe.if(namespaces || isMacOS)('a deny with a link on the way', () => { + /** What `attempt` came to, run in ROOT with every one of `denied` denied. */ + async function outcomeOf(attempt: string, denied = DENIED): Promise { + const options = denying(denied, { + command: `cd ${ROOT} && if ( ${attempt} ); then echo DONE; else echo refused; fi`, + // Linux mounts a read deny where it resolves, under the /dev it then + // makes anew, and bubblewrap before 0.5.0 takes no device to mount on. + readConfig: { denyOnly: isMacOS ? inRoot(['far']) : [] }, + }) + const { stdout } = run( + isLinux + ? await wrapCommandWithSandboxLinux(options) + : wrapCommandWithSandboxMacOS(options), + ) + SandboxManager.cleanupAfterCommand() + return stdout.trim() + } + + it.each([ + ...Object.keys(LINKS).flatMap(link => [ + `rm ${link}`, + `mv ${link} aside`, + `ln -sfn beside ${link}`, + ]), + ...['name', 'dangling', 'middle'].map( + link => `echo new > made && mv -f made ${link}`, + ), + // A write or a create through the links, and by the path they lead to. + ...[...new Set(WAYS.flatMap(([name, , end]) => [name, end]))].map( + at => `echo changed >> ${at} || echo new > ${at}/new`, + ), + 'rm conf/x', + // The directories above a link and above what it leads to, those the way + // goes up from, and the names that are not there. + 'mv holder aside', + 'mv conf aside', + 'mv in/to aside', + 'mv in aside', + 'mkdir lost', + 'ln -s conf gone', + ])('refuses: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('refused') + + for (const [link, says] of Object.entries(LINKS)) { + expect(fs.readlinkSync(join(ROOT, link))).toBe(says) + } + for (const file of FILES) { + expect(fs.readFileSync(join(ROOT, file), 'utf8')).toBe('original') + } + expect(inRoot(ABSENT).filter(fs.existsSync)).toEqual([]) + }) + + it.each([ + 'echo changed >> beside && rm beside', + 'echo new > new && echo new > holder/new', + 'echo changed >> conf/z', + 'ln -s conf/z free && ln -sfn beside free', + 'test "$(readlink name)$(cat name way/secret)" = conf/xoriginaloriginal', + 'echo x > /dev/null && cat /dev/null', + ])('allows: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('DONE') + }) + + it.each(['rm .bashrc', 'echo changed >> conf/z', 'rm .claude/commands'])( + 'the same of the names denied in every working directory: %s', + async attempt => { + fs.mkdirSync(join(ROOT, '.claude')) + fs.symlinkSync('conf/z', join(ROOT, '.bashrc')) + fs.symlinkSync('../shared/commands', join(ROOT, '.claude', 'commands')) + process.chdir(ROOT) + expect(await outcomeOf(attempt, [])).toBe('refused') + }, + ) + + it.if(modesCount)('a directory it cannot look in is kept whole', async () => { + expect(await outcomeOf('chmod 000 holder')).toBe('DONE') + const undoing = 'chmod 755 holder; rm holder/commands || rm -r holder' + expect(await outcomeOf(undoing)).toBe('refused') + expect(fs.lstatSync(HOLDER).mode & 0o777).toBe(0) + fs.chmodSync(HOLDER, 0o755) + expect(fs.readdirSync(HOLDER).sort()).toEqual(['blank', 'commands']) + }) +}) + +// Profile text only, so it runs on every POSIX host. +describe.if(!isWindows)('Seatbelt profile', () => { + /** The filters of the rule that denies `ops`, one to a line. */ + function deniedBy(ops: string, write: string[], read: string[] = []): string { + const profile = wrapCommandWithSandboxMacOS( + denying(write, { readConfig: { denyOnly: read } }), + ) + const at = profile.indexOf(`(deny ${ops}\n`) + expect(at).toBeGreaterThan(-1) + return profile.slice(at, profile.indexOf('(with message', at)) + } + const KEPT = 'file-write-unlink file-write-create' + const subpath = (name: string): string => `(subpath "${join(ROOT, name)}")\n` + const literal = (name: string): string => `(literal "${join(ROOT, name)}")\n` + + it.each(WAYS)('%s keeps %p, denies %s', (name, links, end, left, passed) => { + const denies = deniedBy('file-write*', inRoot([name])) + for (const at of [name, end, ...passed]) { + expect(denies).toContain(subpath(at)) + } + const kept = deniedBy(KEPT, inRoot([name])) + for (const at of [...links, ...left]) expect(kept).toContain(literal(at)) + }) + + it('so is one on a name denied in every working directory', () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + expect(deniedBy('file-write*', [])).toContain(subpath('conf/x')) + }) + + it('a pattern deny starts from both spellings of its directory', () => { + const denies = deniedBy('file-write*', inRoot(['way/*.key'])) + const matches = (file: string): boolean => + [...denies.matchAll(/^ {2}\(regex (".*")\)$/gm)].some(([, text]) => + new RegExp(JSON.parse(text!) as string).test(join(ROOT, file)), + ) + expect(matches('way/a.key')).toBe(true) + expect(matches('real/a.key')).toBe(true) + expect(matches('conf/a.key')).toBe(false) + }) + + it('nothing among the devices is denied or kept; the links to them are kept, and what holds one', () => { + fs.symlinkSync('/dev', join(ROOT, 'devices')) + const denied = inRoot(['far', 'out', 'devices']) + const kept = deniedBy(KEPT, denied) + expect(deniedBy('file-write*', denied) + kept).not.toContain('"/dev') + for (const name of ['far', 'holder/blank', 'holder', 'out', 'devices']) { + expect(kept).toContain(literal(name)) + } + }) + + it('nor is a device hidden by a read deny on a link to it', () => { + const denies = deniedBy('file-read*', [], inRoot(['far', 'name'])) + expect(denies).toContain(subpath('conf/x')) + expect(denies).not.toContain('"/dev') + }) + + it.if(modesCount)('a directory it cannot look in is denied whole', () => { + fs.chmodSync(HOLDER, 0) + expect(deniedBy('file-write*', inRoot(['holder/commands']))).toContain( + subpath('holder'), + ) + }) +}) + +function wrapLinux(denied: string[], rest?: Rest): Promise { + return wrapCommandWithSandboxLinux(denying(denied, rest)) +} + +/** An executable file in OUTSIDE that holds `script`. */ +function program(name: string, script: string): string { + const file = join(OUTSIDE, name) + fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) + return file +} + +/** + * The words a wrap hands the seccomp helper before the command, as [link in + * ROOT, target] pairs. Read by the shells themselves, through every level of + * quoting, and with no namespace made: a stand-in for bubblewrap runs what + * follows `--`, three words, and one for the helper only prints its words. + */ +async function heldBy(denied: string[], rest?: Rest): Promise { + const bwrapPath = program('bwrap-stand-in', 'shift $(($# - 3)); exec "$@"') + const applyPath = program('helper-stand-in', `printf '%s\\0' "$@"`) + const seccompConfig = { applyPath, holdsLinks: true } + const wrapped = await wrapLinux(denied, { bwrapPath, seccompConfig, ...rest }) + const words = run(wrapped).stdout.split('\0') + const held: string[][] = [] + for (let i = 0; words[i] === '--hold-link'; i += 3) { + held.push([relative(ROOT, words[i + 1]!), words[i + 2]!]) + } + expect(words.slice(3 * held.length + 1)).toEqual(['-c', 'echo started', '']) + return held.sort() +} + +describe.if(isLinux)('what the seccomp helper is asked to hold', () => { + it('each link once, with what it says; none for a path with no link on the way, or that lies where the command cannot write', async () => { + fs.symlinkSync(join(ROOT, 'conf', 'x'), join(OUTSIDE, 'in')) + const none = [...inRoot(['beside', 'absent']), join(OUTSIDE, 'in')] + expect(await heldBy([...DENIED, ...none])).toEqual( + Object.entries(LINKS).sort(), + ) + }) + + it('whatever characters the name and the target hold, U+FFFD too, with no word on a target that is no text', async () => { + const odd = ["sp ace'quote", 'new\nline', '$(echo x)`y`', 'x�y'] + for (const name of odd) fs.symlinkSync(`to ${name}`, join(ROOT, name)) + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) + expect(await heldBy(inRoot([...odd, 'bytes']))).toEqual( + [...odd.map(name => [name, `to ${name}`]), ['bytes', '']].sort(), + ) + }) + + it('for a deny written from the home or the working directory, and a name denied in every one', async () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + const fromHome = `~/${relative(homedir(), join(ROOT, 'way', 'x'))}` + expect(await heldBy(['name', fromHome])).toEqual([ + ['.bashrc', 'conf/x'], + ...said(['name', 'way']), + ]) + }) + + it('under a write root that is the root, but none in the directories the sandbox mounts anew', async () => { + const denyWithinAllow = ['/proc/self/cwd/file', ...inRoot(['name', 'out'])] + expect( + await heldBy([], { writeConfig: { allowOnly: ['/'], denyWithinAllow } }), + ).toEqual(said(['name', 'out'])) + }) + + it('none in a directory that a read deny hides, though the link is bound back by itself', async () => { + fs.mkdirSync(join(HOLDER, 'inner')) + fs.symlinkSync('inner', join(HOLDER, 'to-inner')) + const denied = inRoot(['holder/to-inner/file', 'holder/commands', 'name']) + const allowWithinDeny = [join(HOLDER, 'to-inner')] + const hiding = { readConfig: { denyOnly: [HOLDER], allowWithinDeny } } + expect(await heldBy(denied, hiding)).toEqual(said(['name'])) + if (namespaces) { + expect(run(await wrapLinux(denied, hiding)).stdout).toBe('started\n') + } + }) + + // In place of the paths in it. + it.if(modesCount)('denies a directory it cannot look in whole', async () => { + fs.chmodSync(HOLDER, 0) + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['holder/commands', 'holder/other'])), + ) + // Beneath the write root's bind it is a pin, after it a deny. + expect( + wrapped.lastIndexOf(`--ro-bind ${HOLDER} ${HOLDER} `), + ).toBeGreaterThan(wrapped.indexOf(`--bind ${ROOT} ${ROOT} `)) + expect(wrapped).not.toContain(`${HOLDER}/`) + expect(warnings.join()).toContain(`denying ${HOLDER} whole`) + }) + + it('the links on the way to a read deny, one that leads to nothing yet too, to a match of a read-deny pattern and to a masked file', async () => { + const [realPath, ...denyOnly] = inRoot(['way/secret', 'first', 'dangling']) + expect( + await heldBy([], { + readConfig: { denyOnly, matchedLinks: inRoot(['name']) }, + maskedFileBinds: [{ realPath: realPath!, fakePath: '/fakes/0' }], + }), + ).toEqual(said(['dangling', 'first', 'middle', 'name', 'way'])) + }) + + it('a read deny that is where it resolves to is not walked', async () => { + const { calls } = spyOn(fs, 'lstatSync').mock + await wrapLinux([], { readConfig: { denyOnly: inRoot(['conf/x']) } }) + expect(calls.filter(([p]) => String(p).startsWith(ROOT))).toEqual([]) + }) + + it('a refusal for length says how much of it names links', async () => { + const refusal = (denied: string[]): Promise => + wrapLinux(denied, { command: 'a'.repeat(2 ** 21) }).catch(String) + const words = `--hold-link ${ROOT}/name conf/x` + expect(await refusal(inRoot(['name']))).toContain( + `At least ${words.length} of them are not the caller's`, + ) + expect(await refusal([])).toMatch(/the limit here is \d+\)$/) + }) +}) + +type Config = Parameters[0] + +/** ROOT for the one write root, and `first` denied in it. */ +const config = (denyRead: string[], rest: Partial = {}): Config => ({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead, allowWrite: [ROOT], denyWrite: inRoot(['first']) }, + ...rest, +}) + +describe.if(isLinux)('where no link is held', () => { + const [NEWER, OLDER] = [join(OUTSIDE, 'newer'), join(OUTSIDE, 'older')] + const listed = SandboxManager.getLinuxUnheldLinks + type Reason = LinuxUnheldLink['reason'] + const unheld = (reason: Reason, ...names: string[]): LinuxUnheldLink[] => + inRoot(names).map(path => ({ path, reason })) + + // The reason if any, `seccomp`, and the helper the library finds for itself. + // The links are named, what holds one is not pinned for it either, and no + // helper is run to find out. + it.each<[Reason | '', Options['seccompConfig'], string]>([ + ['no_helper', {}, ''], + ['told_no', { holdsLinks: false }, ''], + ['told_no', { applyPath: NEWER, holdsLinks: false }, ''], + ['not_told', { applyPath: NEWER }, ''], + // What the library finds where nothing is at the path says nothing of it. + ['not_told', { applyPath: '/in/the/sandbox', argv0: 'apply-seccomp' }, ''], + ['', { applyPath: OLDER, holdsLinks: true }, ''], + ['', {}, NEWER], + ['older_helper', {}, OLDER], + ['no_answer', {}, OUTSIDE], + ])('"%s", told %j and finding "%s"', async (reason, seccompConfig, own) => { + // One names the option that says so, one runs its words as older builds do. + program('newer', `echo run > ${OUTSIDE}/run # --holds-links`) + program('older', `echo run > ${OUTSIDE}/run; exec "$@"`) + if (own) spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue(own) + const allowAllUnixSockets = reason === 'no_helper' + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['far']), { seccompConfig, allowAllUnixSockets }), + ) + + for (const words of ['--hold-link', `--ro-bind ${HOLDER} ${HOLDER} `]) { + expect(wrapped.includes(words)).toBe(reason === '') + } + const links = reason ? unheld(reason, 'far', 'holder/blank') : [] + expect(listed()).toEqual(links) + for (const { path } of links) { + expect( + warnings.some(l => l.includes(`${path},`) && l.includes(reason)), + ).toBe(true) + } + expect(fs.existsSync(join(OUTSIDE, 'run'))).toBe(false) + if (namespaces && !seccompConfig?.applyPath && !own) { + expect(run(wrapped).stdout).toBe('started\n') + } + }) + + it.each<[string, () => void | Promise]>([ + ['reset()', () => SandboxManager.reset()], + ['updateConfig()', () => SandboxManager.updateConfig(config([]))], + ])('listed by the latest wrap to meet it, until %s', async (_what, act) => { + await wrapLinux(inRoot(['first']), { allowAllUnixSockets: true }) + await wrapLinux(inRoot(['first']), { seccompConfig: { holdsLinks: false } }) + expect(await wrapLinux(inRoot(['middle']))).toContain('--hold-link') + expect(listed()).toEqual(unheld('told_no', 'first')) + await act() + expect(listed()).toEqual([]) + }) + + it('a wrap that throws names none', async () => { + const throwing = { allowAllUnixSockets: true, binShell: 'no-such-shell' } + const refusal = await wrapLinux(inRoot(['first']), throwing).catch(String) + expect(refusal).toContain('no-such-shell') + expect(listed()).toEqual([]) + }) + + it('what the way goes up from is pinned all the same where the command can replace no link on it', async () => { + fs.symlinkSync(`${ROOT}/${LINKS.up}`, join(OUTSIDE, 'up')) + const wrapped = await wrapLinux([join(OUTSIDE, 'up')], { + seccompConfig: { holdsLinks: false }, + }) + for (const dir of inRoot(['in', 'in/to'])) { + expect(wrapped).toContain(`--ro-bind ${dir} ${dir} `) + } + expect(listed()).toEqual([]) + }) + + it('the manager takes the word on the helper from the configuration, and names the links its patterns match', async () => { + await SandboxManager.initialize( + SandboxRuntimeConfigSchema.parse( + config(inRoot(['nam*', 'besid*']), { seccomp: { holdsLinks: false } }), + ), + ) + expect(SandboxManager.getFsReadConfig().matchedLinks).toEqual( + inRoot(['name']), + ) + await SandboxManager.wrapWithSandbox('true') + expect(listed()).toEqual(unheld('told_no', 'first', 'middle', 'name')) + }) +}) + +describe.if(namespaces)('the seccomp helper', () => { + const helper = (): string => seccomp.getApplySeccompBinaryPath()! + /** For a shell in ROOT. With no word on what the first of them says. */ + const HOLDS = + '--hold-link name "" --hold-link way real --hold-link dangling later' + /** Lifts the helper's namespaces filter, which refuses every mount call. */ + const NAMESPACES_ALLOWED = 'SRT_ALLOW_NESTED_USERNS=1' + const ELSEWHERE = 'leads elsewhere than it did' + + it('says that it holds links', () => { + expect(run(`${helper()} --holds-links`).status).toBe(0) + }) + + it.each([ + ['a file is at the name', 'beside', '""', 'not a symbolic link'], + ['nothing is at the name', 'absent', '""', 'No such file or directory'], + ['the link says more', 'name', 'conf', ELSEWHERE], + ['the link says less', 'name', 'conf/xy', ELSEWHERE], + ['the link says another thing', 'name', 'conf/y', ELSEWHERE], + ])('runs no command where %s, and says why', (_what, name, target, why) => { + expect( + run(`${helper()} --hold-link ${name} ${target} echo ran 2>&1`, ROOT), + ).toMatchObject({ + status: 1, + stdout: `apply-seccomp: --hold-link ${name}: ${why} (seccomp.holdsLinks: false in the sandbox configuration holds no link)\n`, + }) + }) + + // It has every capability in its namespace. The filter refuses the unmount; + // where it is lifted, the lock does. + it.each([ + ['', 'EPERM'], + [NAMESPACES_ALLOWED, 'EINVAL'], + ])('%s a command of uid 0 cannot unmount the hold', (env, errno) => { + // umount2() with UMOUNT_NOFOLLOW, which reaches a mount on a link. + const unmount = + 'import ctypes, errno; ctypes.CDLL(None, use_errno=True).umount2(b"name", 8); print(errno.errorcode[ctypes.get_errno()])' + const { stdout } = run( + `${env} unshare -Ur ${helper()} ${HOLDS} sh -c 'python3 -c "$0"; rm name 2>&1' '${unmount}'`, + ROOT, + ) + expect(stdout).toStartWith(`${errno}\n`) + expect(stdout).toMatch(BUSY) + }) + + it('a hold keeps the flags of the file system that holds the link', () => { + const { stdout } = run( + `unshare -Urm sh -c 'mount -t tmpfs -o noexec,noatime,nosymfollow none ${OUTSIDE} && ` + + `cd ${OUTSIDE} && echo read > x && ln -s x link && ` + + `exec ${helper()} --hold-link link x sh -c "rm link; cat link"' 2>&1`, + ) + expect(stdout).toMatch(BUSY) + expect(stdout).toContain('Too many levels of symbolic links') + expect(stdout).not.toContain('read') + }) + + it.each(['--ro-bind', '--bind', '--dev-bind'])( + 'a bubblewrap the command may start, with %s / /, starts, and the hold holds there', + bind => { + const { stdout } = run( + `${NAMESPACES_ALLOWED} ${helper()} ${HOLDS} bwrap ${bind} / / --bind ${ROOT} ${ROOT} --dev /dev sh -c 'echo started; rm name way dangling' 2>&1`, + ROOT, + ) + expect(stdout).toContain('started') + expect(stdout.match(new RegExp(BUSY, 'gi'))).toHaveLength(3) + }, + ) + + it('a link that says another thing by the time the command runs: nothing runs', async () => { + const wrapped = await wrapLinux(inRoot(['name'])) + fs.rmSync(join(ROOT, 'name')) + fs.symlinkSync('conf/z', join(ROOT, 'name')) + expect(run(wrapped)).toMatchObject({ + stdout: '', + stderr: expect.stringContaining(`--hold-link ${ROOT}/name: ${ELSEWHERE}`), + }) + }) +}) + +// The name stays, and what it leads to stays hidden. +describe.if(namespaces)('a read deny on a link, over two commands', () => { + it.each<[string, string, string[], Array<'deny' | 'mask'>]>([ + ['denyRead', 'file', [''], []], + ['denyRead', 'dir', [''], []], + ['a denyRead pattern', 'file', ['*'], []], + ['a credential deny', 'file', [], ['deny']], + ['a credential mask', 'file', [], ['mask']], + ])('%s on a link to a %s', async (_kind, leadsTo, endings, modes) => { + const [name, target] = [join(ROOT, 'linked'), join(OUTSIDE, leadsTo)] + fs.mkdirSync(join(OUTSIDE, 'dir')) + fs.writeFileSync(join(OUTSIDE, 'file'), 'secret') + fs.writeFileSync(join(OUTSIDE, 'dir', 'file'), 'secret') + fs.symlinkSync(target, name) + const files = modes.map(mode => ({ path: name, mode })) + const denied = endings.map(ending => name + ending) + await SandboxManager.initialize(config(denied, { credentials: { files } })) + const saidBy = async (command: string): Promise => { + const { stdout } = run( + await SandboxManager.wrapWithSandbox(`echo ran; ${command}`), + ) + SandboxManager.cleanupAfterCommand() + expect(stdout).toStartWith('ran\n') + return stdout + } + + await saidBy(`rm ${name}; mv ${name} ${name}-aside; echo x > ${name}`) + expect(fs.readlinkSync(name)).toBe(target) + expect(await saidBy(`cat ${target} ${target}/file`)).not.toContain('secret') + }) +}) diff --git a/test/smoke/bundled.test.ts b/test/smoke/bundled.test.ts new file mode 100644 index 000000000..3d78f3491 --- /dev/null +++ b/test/smoke/bundled.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Compiles embedder.ts, which says why, the ways an embedding application is + * built, and runs each result from an empty directory, so that nothing of the + * package is on disk around it. Not quite nothing: a bundle records where its + * modules were when it was built, so on the machine that built it a lookup + * from the library's own location still reaches this checkout. + */ +describe.skipIf(isWindows).each([ + { form: 'one file', flags: [] }, + // Modules are evaluated in another order, the library in a chunk of its own. + { form: 'split ESM chunks', flags: ['--splitting', '--format=esm'] }, +])('the built package, minified and as bytecode in $form', ({ flags }) => { + let home: string + + beforeAll(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), 'srt-embedder-'))) + const build = spawnSync( + process.execPath, + [ + ...['build', '--compile', '--minify', '--bytecode', ...flags], + join(import.meta.dir, 'embedder.ts'), + ...['--outfile', join(home, 'embedder')], + ], + { encoding: 'utf8' }, + ) + expect( + `status ${build.status}\n${build.stdout}${build.stderr}`, + ).toStartWith('status 0\n') + if (isLinux) { + copyFileSync( + join( + import.meta.dir, + '../../vendor/seccomp', + process.arch, + 'apply-seccomp', + ), + join(home, 'apply-seccomp'), + ) + } + }, 120_000) + + afterAll(() => rmSync(home, { recursive: true, force: true })) + + test.each([ + { how: 'told where the seccomp helper is', multicall: undefined }, + ...(isLinux + ? [ + { + how: 'reaching the helper and ripgrep through argv0', + multicall: '1', + }, + ] + : []), + ])( + 'wraps a command, $how', + ({ multicall }) => { + const run = spawnSync(join(home, 'embedder'), [], { + cwd: home, + env: { + ...process.env, + SRT_SMOKE_APPLY_SECCOMP: join(home, 'apply-seccomp'), + SRT_SMOKE_MULTICALL: multicall, + }, + encoding: 'utf8', + }) + expect(`${run.stdout}${run.stderr}${String(run.error ?? '')}`).toEndWith( + 'SMOKE OK\n', + ) + expect(run.status).toBe(0) + }, + 120_000, + ) +}) diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts new file mode 100644 index 000000000..147b6f507 --- /dev/null +++ b/test/smoke/embedder.ts @@ -0,0 +1,221 @@ +/** + * What an embedder ships, in miniature. bundled.test.ts compiles this and the + * BUILT package (dist/) into one minified executable of bytecode, where a + * function's name, its source text and the files beside it are all gone. It + * wraps a real command, checks what that did on the host, and checks that a + * refusal keeps the name and the code a caller branches on. The library comes + * through `import()`, so that a split bundle puts it in a chunk of its own. + * + * SRT_SMOKE_APPLY_SECCOMP names the seccomp helper. With SRT_SMOKE_MULTICALL=1 + * the helper and ripgrep are reached as by an embedder that carries both: by + * starting this executable under their names (`seccomp.argv0` with fd 3 open + * on it, and `ripgrep.argv0`). + */ +import { spawnSync } from 'node:child_process' +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs' +import { once } from 'node:events' +import { createServer, type AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' + +// The multicall part. The library names the helper in ARGV0, since nothing +// between it and the exec inside the sandbox can set argv[0]. +const { ARGV0, ...env } = process.env +const name = ARGV0 === 'apply-seccomp' ? ARGV0 : basename(process.argv0) +if (name === 'apply-seccomp' || name === 'rg') { + appendFileSync(env.SRT_SMOKE_MULTICALL_LOG ?? '/dev/null', `${name}\n`) + const ran = spawnSync( + name === 'rg' ? name : (env.SRT_SMOKE_APPLY_SECCOMP ?? name), + process.argv.slice(2), + { stdio: 'inherit', env }, + ) + if (ran.error !== undefined) console.error(`${name}: ${String(ran.error)}`) + process.exit(ran.status ?? 127) +} + +function check(what: string, ok: boolean, detail = ''): void { + if (!ok) throw new Error(`FAILED: ${what}\n${detail}`) + console.log(`ok: ${what}`) +} + +// Bytecode is CommonJS, which has no top-level await. +async function main(): Promise { + const { SandboxManager, LinuxSandboxProfileError } = await import( + '../../dist/index.js' + ) + const multicall = process.env.SRT_SMOKE_MULTICALL === '1' + const applyPath = process.env.SRT_SMOKE_APPLY_SECCOMP + const root = realpathSync(mkdtempSync(join(tmpdir(), 'srt-bundled-smoke-'))) + const work = join(root, 'work') + const outside = join(root, 'outside') + mkdirSync(work) + mkdirSync(outside) + // Where a wrapped command can write: the helper is started inside. + const multicallLog = join(work, 'multicall.log') + if (multicall) process.env.SRT_SMOKE_MULTICALL_LOG = multicallLog + + const config = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [work], + denyWrite: [join(work, 'denied.txt')], + }, + // The container job says it is one the way it says it to its suite. + enableWeakerNestedSandbox: process.env.SRT_E2E_DOCKER === '1', + ...(multicall + ? { + seccomp: { applyPath: '/proc/self/fd/3', argv0: 'apply-seccomp' }, + ripgrep: { command: process.execPath, args: [], argv0: 'rg' }, + } + : { seccomp: { applyPath } }), + } + + try { + await SandboxManager.initialize(config) + + // After initialize, so that the configured helper is the one looked for. + const { warnings } = SandboxManager.checkDependencies() + check( + 'the dependency check finds the seccomp helper', + !warnings.some(w => w.includes('seccomp')), + warnings.join('\n'), + ) + + // A listener on the host, which the same words reach from outside the + // sandbox: without that, "blocked" could mean a shell that cannot connect. + const listener = createServer().listen(0, '127.0.0.1').unref() + await once(listener, 'listening') + const { port } = listener.address() as AddressInfo + const connect = `(exec 9<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED` + check( + 'the listener is reachable from outside the sandbox', + spawnSync('bash', ['-c', connect], { encoding: 'utf8' }).stdout === + 'NET-OPEN\n', + ) + + const wrapped = await SandboxManager.wrapWithSandbox( + [ + 'echo BOOTED', + 'echo allowed > allowed.txt', + 'echo denied > denied.txt 2>/dev/null || echo WRITE-DENIED', + 'echo outside > ../outside/x.txt 2>/dev/null || echo OUTSIDE-DENIED', + connect, + 'grep -q "^Seccomp:[[:space:]]*2" /proc/self/status 2>/dev/null && echo SECCOMP-ON || echo SECCOMP-OFF', + 'exit 7', + ].join('; '), + ) + check( + 'the seccomp helper it was told of is the one the command starts', + process.platform !== 'linux' || + multicall || + (applyPath !== undefined && wrapped.includes(applyPath)), + wrapped, + ) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: work, + timeout: 60000, + stdio: multicall + ? ['ignore', 'pipe', 'pipe', openSync(process.execPath, 'r')] + : 'pipe', + }) + const output = `${result.stdout}${result.stderr}` + check('the wrapped command started', output.includes('BOOTED'), output) + check( + 'a write inside the allowed directory lands', + existsSync(join(work, 'allowed.txt')) && + readFileSync(join(work, 'allowed.txt'), 'utf8') === 'allowed\n', + output, + ) + check( + 'a write to a denied path inside it does not', + output.includes('WRITE-DENIED') && + (!existsSync(join(work, 'denied.txt')) || + readFileSync(join(work, 'denied.txt'), 'utf8') === ''), + output, + ) + check( + 'a write outside it does not', + output.includes('OUTSIDE-DENIED') && !existsSync(join(outside, 'x.txt')), + output, + ) + check( + 'the network is closed', + output.includes('NET-BLOCKED') && !output.includes('NET-OPEN'), + output, + ) + check( + 'the command ran under the seccomp filter', + process.platform !== 'linux' || output.includes('SECCOMP-ON'), + output, + ) + for (const program of multicall ? ['rg', 'apply-seccomp'] : []) { + const started = readFileSync(multicallLog, 'utf8') + check( + `this executable was started as ${program}`, + started.split('\n').includes(program), + started, + ) + } + check( + "the command's own exit status comes back", + result.status === 7, + `status ${String(result.status)}\n${output}`, + ) + + SandboxManager.cleanupAfterCommand() + check( + 'the mount point for the denied path is gone after the command', + !existsSync(join(work, 'denied.txt')), + ) + check( + 'the configuration reads back', + SandboxManager.getConfig()?.filesystem.allowWrite.includes(work) === true, + ) + + if (process.platform === 'linux') { + // More absent deny paths than bubblewrap takes arguments for: the wrap + // is refused before anything is spawned or written. + SandboxManager.updateConfig({ + ...config, + filesystem: { + ...config.filesystem, + denyWrite: Array.from({ length: 4000 }, (_, i) => + join(work, `absent-${i}`), + ), + }, + }) + const refusal: unknown = await SandboxManager.wrapWithSandbox( + 'true', + ).catch((error: unknown) => error) + check( + 'a profile that does not fit is refused with an error that keeps its type, name and code', + refusal instanceof LinuxSandboxProfileError && + refusal.name === 'LinuxSandboxProfileError' && + refusal.code === 'too_many_arguments', + String(refusal), + ) + } + console.log('SMOKE OK') + } finally { + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + } +} + +main().catch((error: unknown) => { + // Not the error itself: the runtime would print the minified source with it. + console.error(error instanceof Error ? error.stack : error) + process.exit(1) +}) diff --git a/test/utils/which-node-test.mjs b/test/utils/which-node-test.mjs index 40cfe5787..6ababee41 100644 --- a/test/utils/which-node-test.mjs +++ b/test/utils/which-node-test.mjs @@ -1,10 +1,13 @@ /** * Test whichSync behavior in Node.js environment - * Run with: node --experimental-vm-modules test/utils/which-node.test.mjs + * Run with: node test/utils/which-node-test.mjs */ import assert from 'node:assert' import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' // Verify we're running in Node.js, not Bun assert.strictEqual( @@ -13,7 +16,7 @@ assert.strictEqual( 'This test must run in Node.js, not Bun', ) -console.log('Running whichSync Node.js fallback tests...') +console.log('Running whichSync Node.js tests...') // Build the project first to get the JS output const buildResult = spawnSync('npm', ['run', 'build'], { @@ -52,7 +55,7 @@ const bashPath = whichSync('bash') assert.ok(bashPath !== null, 'whichSync should find bash') console.log('✓ Found bash at:', bashPath) -// Test 4: Verify spawnSync is being used (by checking behavior matches which command) +// Test 4: The search in this process finds what the which command finds const whichResult = spawnSync('which', ['ls'], { encoding: 'utf8' }) const expectedPath = whichResult.stdout.trim() assert.strictEqual( @@ -62,4 +65,21 @@ assert.strictEqual( ) console.log('✓ Output matches which command') -console.log('\n✅ All Node.js fallback tests passed!') +// Test 5: No which command is run: one first on PATH, where a sandboxed +// command may have left it, would run on the host +const planted = mkdtempSync(join(tmpdir(), 'which-node-test-')) +const savedPath = process.env.PATH +try { + writeFileSync(join(planted, 'which'), `#!/bin/sh\ntouch '${planted}/ran'\n`, { + mode: 0o755, + }) + process.env.PATH = `${planted}:${savedPath}` + assert.strictEqual(whichSync('ls'), lsPath) + assert.ok(!existsSync(join(planted, 'ran')), 'the planted which was run') +} finally { + process.env.PATH = savedPath + rmSync(planted, { recursive: true, force: true }) +} +console.log('✓ Runs no which command') + +console.log('\n✅ All Node.js tests passed!') diff --git a/test/utils/which.test.ts b/test/utils/which.test.ts index c4fb03754..d11dcc59e 100644 --- a/test/utils/which.test.ts +++ b/test/utils/which.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from 'bun:test' +import { afterAll, afterEach, describe, it, expect } from 'bun:test' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { whichSync } from '../../src/utils/which.js' /** @@ -8,7 +11,7 @@ import { whichSync } from '../../src/utils/which.js' * which mocks the which.js module globally. Run with: * bun test test/utils/which.test.ts * - * The Node.js fallback is tested separately in which-node-test.mjs + * The same code runs under Node.js: see which-node-test.mjs */ describe('whichSync', () => { it('should find existing executables', () => { @@ -32,17 +35,66 @@ describe('whichSync', () => { expect(cat).not.toBeNull() }) - it('should be running in Bun environment', () => { - // Verify we're in Bun - if this fails, Bun.which won't be used - expect(typeof globalThis.Bun).toBe('object') - expect(typeof globalThis.Bun.which).toBe('function') - }) - it('should return same result as Bun.which directly', () => { - // Verify whichSync returns same result as Bun.which - // This indirectly confirms Bun.which is being used + // Of the PATH the process started with, the only one Bun.which searches const whichSyncResult = whichSync('ls') const bunWhichResult = globalThis.Bun.which('ls') expect(whichSyncResult).toBe(bunWhichResult) }) }) + +describe('whichSync searches PATH in this process', () => { + const savedPath = process.env.PATH + const savedCwd = process.cwd() + const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'which-test-'))) + + function plant(...parts: string[]): string { + const file = join(base, ...parts) + fs.mkdirSync(dirname(file), { recursive: true }) + fs.writeFileSync(file, '#!/bin/sh\n', { mode: 0o755 }) + return file + } + + afterEach(() => { + process.env.PATH = savedPath + process.chdir(savedCwd) + }) + afterAll(() => fs.rmSync(base, { recursive: true, force: true })) + + it('takes the first entry holding an executable regular file of that name', () => { + fs.mkdirSync(join(base, 'directory', 'tool'), { recursive: true }) + fs.chmodSync(plant('no-x', 'tool'), 0o644) + fs.symlinkSync('absent', join(dirname(plant('dangling', 'x')), 'tool')) + const first = plant('first', 'tool') + plant('second', 'tool') + // The PATH of the moment, which a spawned child would inherit. + process.env.PATH = ['directory', 'no-x', 'dangling', 'first', 'second'] + .map(dir => join(base, dir)) + .join(':') + expect(whichSync('tool')).toBe(first) + expect(whichSync('tool-that-is-nowhere')).toBeNull() + }) + + it('checks a name with a directory part where it is', () => { + const tool = plant('elsewhere', 'tool') + plant('on-path', 'sub', 'tool') + process.env.PATH = join(base, 'on-path') + expect(whichSync(tool)).toBe(tool) + expect(whichSync(`${tool}-absent`)).toBeNull() + expect(whichSync(dirname(tool))).toBeNull() + // Relative to the working directory, never to a PATH entry. + expect(whichSync('sub/tool')).toBeNull() + }) + + it('reads an empty entry as the current directory, an empty PATH as no place', () => { + process.chdir(dirname(plant('cwd', 'tool'))) + for (const searched of [':/absent', '/absent:', '.']) { + process.env.PATH = searched + expect(whichSync('tool')).toBe('./tool') + } + process.env.PATH = '' + expect(whichSync('tool')).toBeNull() + delete process.env.PATH + expect(whichSync('tool')).toBeNull() + }) +}) diff --git a/vendor/seccomp-src/apply-seccomp.c b/vendor/seccomp-src/apply-seccomp.c index c7c020bc6..39530aab0 100644 --- a/vendor/seccomp-src/apply-seccomp.c +++ b/vendor/seccomp-src/apply-seccomp.c @@ -1,7 +1,8 @@ /* * apply-seccomp.c - Apply seccomp BPF filter in an isolated PID namespace * - * Usage: apply-seccomp [args...] + * Usage: apply-seccomp [--hold-link ]... [args...] + * apply-seccomp --holds-links * * This program applies a baked-in seccomp BPF filter, isolates the * target command in a nested user+PID+mount namespace so it cannot see or @@ -38,6 +39,10 @@ * filter. SRT_ALLOW_NESTED_USERNS=1, read before the command exists and * removed from its environment, turns both off. * + * --hold-link keeps a symbolic link where it is: see hold_link(). The second + * form exits 0 and does nothing else. A build that lacks the option takes the + * word for a command, finds none, and exits 1. + * * Compile: gcc -static -O2 -o apply-seccomp apply-seccomp.c */ @@ -57,6 +62,8 @@ #include #include #include +#include +#include #include #include #include @@ -83,6 +90,13 @@ #define SECCOMP_MODE_FILTER 2 #endif +#ifndef MS_NOSYMFOLLOW +#define MS_NOSYMFOLLOW 256 +#endif +#ifndef ST_NOSYMFOLLOW +#define ST_NOSYMFOLLOW 0x2000 +#endif + #ifndef SECCOMP_FILTER_FLAG_NEW_LISTENER #define SECCOMP_FILTER_FLAG_NEW_LISTENER (1UL << 3) #endif @@ -670,13 +684,134 @@ static int reap_until(pid_t main_child) { } } +/* A new user namespace, with this process's uid and gid mapped to themselves + * and every capability in it. */ +static void enter_user_namespace(void) { + uid_t uid = geteuid(); + gid_t gid = getegid(); + + /* If this binary was exec'd without read permission (e.g. installed + * mode 0111), the kernel marked the process non-dumpable, which + * makes /proc/self/{setgroups,uid_map,gid_map} root-owned, so the + * writes below would fail with EACCES. Temporarily flip dumpable + * on for the uid/gid mapping; it is cleared right after. While + * dumpable is 1, a same-uid process can ptrace us (under yama + * ptrace_scope=0) and dump the mapped pages that mode 0111 is + * meant to hide; that exposure is kept to a + * few-syscall race window — the same unavoidable window runc and + * systemd accept for this pattern. + * + * A prctl failure here is ignored: it is next to impossible for + * this call, and if raising dumpable did fail the map writes + * below fail with their own clearer errors. */ + (void)prctl(PR_SET_DUMPABLE, 1); + + if (unshare(CLONE_NEWUSER) < 0) { + die("apply-seccomp: unshare(CLONE_NEWUSER)"); + } + if (write_file("/proc/self/setgroups", "deny") < 0) { + die("apply-seccomp: write /proc/self/setgroups " + "(nested userns is capability-restricted; " + "caller must provide CAP_SYS_ADMIN)"); + } + if (write_file("/proc/self/uid_map", "%u %u 1\n", uid, uid) < 0) { + die("apply-seccomp: write /proc/self/uid_map"); + } + if (write_file("/proc/self/gid_map", "%u %u 1\n", gid, gid) < 0) { + die("apply-seccomp: write /proc/self/gid_map"); + } + /* Zero whatever the flag was on entry, as before the fork in main(): the + * links are held between two calls of this, which is no few syscalls. */ + if (prctl(PR_SET_DUMPABLE, 0) < 0) { + die("apply-seccomp: prctl(PR_SET_DUMPABLE)"); + } +} + +static void die_holding(const char *path, const char *why) { + fprintf(stderr, + "apply-seccomp: --hold-link %s: %s (seccomp.holdsLinks: false in the " + "sandbox configuration holds no link)\n", + path, why); + _exit(1); +} + +/* + * Mount the symbolic link at `path` on itself, read-only. unlink and rename + * refuse a name that is a mount point in the caller's mount namespace (EBUSY), + * and the directory that holds it stays as writable as it was. The link reads + * and leads as before. + * + * `target` is what the link said when the denies were placed by it, or "" for + * no word on that. One that says something else by now leads past them. + * + * mount(2) follows a link at its target, so the link is named through a + * descriptor of itself: the kernel does not follow what /proc/self/fd/N leads + * to any further. What is looked at and what is mounted are one descriptor. + */ +static void hold_link(const char *path, const char *target) { + char self[32]; + char says[PATH_MAX]; + struct stat st; + struct statvfs fs; + + int fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || fstat(fd, &st) < 0) { + die_holding(path, strerror(errno)); + } + if (!S_ISLNK(st.st_mode)) { + die_holding(path, "not a symbolic link"); + } + ssize_t length = readlinkat(fd, "", says, sizeof(says)); + if (length < 0) { + die_holding(path, strerror(errno)); + } + if (*target && ((size_t)length != strlen(target) || + memcmp(says, target, (size_t)length) != 0)) { + die_holding(path, "leads elsewhere than it did"); + } + snprintf(self, sizeof(self), "/proc/self/fd/%d", fd); + if (mount(self, self, NULL, MS_BIND, NULL) < 0) { + die_holding(path, strerror(errno)); + } + close(fd); + + /* Read-only, nosuid and nodev are all the flags bubblewrap ever adds. One + * that the command starts applies its flags to every mount it finds, by + * path, which for this mount leads elsewhere; it leaves a mount that has + * them already alone. The flags the mount came with are given again: + * noexec is locked, and without nosymfollow the link would lead where it + * did not. A remount keeps the access-time flags by itself. Opened anew: + * the first descriptor is of what now lies beneath the mount. */ + fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || fstatvfs(fd, &fs) < 0) { + die_holding(path, strerror(errno)); + } + unsigned long flags = MS_RDONLY | MS_NOSUID | MS_NODEV; + if (fs.f_flag & ST_NOEXEC) flags |= MS_NOEXEC; + if (fs.f_flag & ST_NOSYMFOLLOW) flags |= MS_NOSYMFOLLOW; + snprintf(self, sizeof(self), "/proc/self/fd/%d", fd); + if (mount(NULL, self, NULL, MS_REMOUNT | MS_BIND | flags, NULL) < 0) { + die_holding(path, strerror(errno)); + } + close(fd); +} + int main(int argc, char *argv[]) { - if (argc < 2) { - fprintf(stderr, "Usage: %s [args...]\n", argv[0]); + if (argc == 2 && strcmp(argv[1], "--holds-links") == 0) { + return 0; + } + int links = 0; + while (3 * links + 3 < argc && strcmp(argv[3 * links + 1], "--hold-link") == 0) { + links++; + } + if (argc < 3 * links + 2) { + fprintf(stderr, + "Usage: %s [--hold-link ]... [args...]\n", + argv[0]); return 1; } - char **command_argv = &argv[1]; + char **command_argv = &argv[3 * links + 1]; /* Read once, from the environment the caller built; the worker's copy is * removed below. */ @@ -719,7 +854,8 @@ int main(int argc, char *argv[]) { * map uid/gid, then unshare. This also works when apply-seccomp is * run standalone outside bwrap. * - * Path (a) is tried first. If we don't have the cap, the + * Path (a) is tried first, unless there are links to hold, which takes + * (b): see the invariant there. If we don't have the cap, the * kernel returns EPERM and we fall through to (b). Path (b) can itself * fail on hosts where unprivileged user namespaces are gated by an LSM * (Ubuntu 24.04's AppArmor restriction, for example) — the unshare @@ -734,44 +870,30 @@ int main(int argc, char *argv[]) { ? open("/proc", O_PATH | O_DIRECTORY | O_CLOEXEC) : -1; - if (unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { - if (errno != EPERM) { + if (links > 0 || unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { + if (links == 0 && errno != EPERM) { die("apply-seccomp: unshare(CLONE_NEWPID|CLONE_NEWNS)"); } - uid_t uid = geteuid(); - gid_t gid = getegid(); - - /* If this binary was exec'd without read permission (e.g. installed - * mode 0111), the kernel marked the process non-dumpable, which - * makes /proc/self/{setgroups,uid_map,gid_map} root-owned, so the - * writes below would fail with EACCES. Temporarily flip dumpable - * on for the uid/gid mapping; it is cleared before the fork. While - * dumpable is 1, a same-uid process can ptrace us (under yama - * ptrace_scope=0) and dump the mapped pages that mode 0111 is - * meant to hide; that exposure is kept to a - * few-syscall race window — the same unavoidable window runc and - * systemd accept for this pattern. - * - * A prctl failure here is ignored: it is next to impossible for - * this call, and if raising dumpable did fail the map writes - * below fail with their own clearer errors. */ - (void)prctl(PR_SET_DUMPABLE, 1); - - if (unshare(CLONE_NEWUSER) < 0) { - die("apply-seccomp: unshare(CLONE_NEWUSER)"); - } + enter_user_namespace(); made_userns = 1; - if (write_file("/proc/self/setgroups", "deny") < 0) { - die("apply-seccomp: write /proc/self/setgroups " - "(nested userns is capability-restricted; " - "caller must provide CAP_SYS_ADMIN)"); - } - if (write_file("/proc/self/uid_map", "%u %u 1\n", uid, uid) < 0) { - die("apply-seccomp: write /proc/self/uid_map"); - } - if (write_file("/proc/self/gid_map", "%u %u 1\n", gid, gid) < 0) { - die("apply-seccomp: write /proc/self/gid_map"); + if (links > 0) { + /* INVARIANT: a user namespace is entered after the last hold. The + * mount namespace made below is then a copy made across that + * boundary, in which every mount is locked: it cannot be + * unmounted, which is all a lock refuses. A command started by uid + * 0 holds all capabilities in its user namespace, and could + * unmount what was mounted in a mount namespace of that one. */ + if (unshare(CLONE_NEWNS) < 0) { + die("apply-seccomp: unshare(CLONE_NEWNS)"); + } + if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) < 0) { + die("apply-seccomp: mount(MS_PRIVATE)"); + } + for (int i = 0; i < links; i++) { + hold_link(argv[3 * i + 2], argv[3 * i + 3]); + } + enter_user_namespace(); } if (unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) { die("apply-seccomp: unshare(CLONE_NEWPID|CLONE_NEWNS) after userns"); diff --git a/vendor/srt-win-src/src/acl.rs b/vendor/srt-win-src/src/acl.rs index fe3aca61a..10831f291 100644 --- a/vendor/srt-win-src/src/acl.rs +++ b/vendor/srt-win-src/src/acl.rs @@ -980,7 +980,8 @@ pub enum SbAce { /// would otherwise pick up). DenyFdc, /// `(D;;DELETE;;;)` — object-only (`NO_INHERIT`) DELETE deny - /// on a placeholder INTERMEDIATE directory. Blocks the sandbox + /// on an INTERMEDIATE directory: of a placeholder chain, or between + /// a kept deny and the root above it. Blocks the sandbox /// from renaming/rmdir'ing the intermediate (which would bypass /// the leaf's stamp) without leaking any semantics onto children: /// a full-mask `(OI)(CI)` deny here would deny reads over the diff --git a/vendor/srt-win-src/src/path_id.rs b/vendor/srt-win-src/src/path_id.rs index 0eb66441f..45b33dee6 100644 --- a/vendor/srt-win-src/src/path_id.rs +++ b/vendor/srt-win-src/src/path_id.rs @@ -451,6 +451,16 @@ pub fn canonical_parent_of(canonical_path: &str) -> Option { .map(|p| p.display().to_string()) } +/// The directories strictly between `root` and `canonical_path`, which +/// lies beneath it, the nearest to the path first. +pub fn dirs_between(root: &str, canonical_path: &str) -> Vec { + std::iter::successors(canonical_parent_of(canonical_path), |p| { + canonical_parent_of(p) + }) + .take_while(|p| p.len() > root.len()) + .collect() +} + // ─── File identity (FILE_ID_INFO) ─────────────────────────────────── /// A file's stable identity on a volume — the @@ -926,6 +936,18 @@ mod tests { ); } + #[test] + fn dirs_between_a_root_and_a_path_beneath_it() { + assert_eq!( + dirs_between(r"\\?\C:\r", r"\\?\C:\r\a\.git\hooks"), + [r"\\?\C:\r\a\.git", r"\\?\C:\r\a"], + ); + assert!(dirs_between(r"\\?\C:\r", r"\\?\C:\r\.bashrc").is_empty()); + assert!(dirs_between(r"\\?\C:\r", r"\\?\C:\r").is_empty()); + // A drive for a root, which is written with its separator. + assert_eq!(dirs_between(r"\\?\D:\", r"\\?\D:\a\f"), [r"\\?\D:\a"]); + } + #[test] fn lossy_canonical_path_detected() { // Lone high surrogate → from_utf16_lossy substitutes diff --git a/vendor/srt-win-src/src/persist.rs b/vendor/srt-win-src/src/persist.rs index fc9a5a650..927521a9e 100644 --- a/vendor/srt-win-src/src/persist.rs +++ b/vendor/srt-win-src/src/persist.rs @@ -479,17 +479,36 @@ fn root_row(conn: &Connection, canon: &str) -> Result> .transpose() } +/// Those of `roots` that are `canon` or a directory above it. +fn covering_roots<'a>(roots: &'a [String], canon: &str) -> impl Iterator { + roots.iter().map(String::as_str).filter(move |r| { + canon == *r + || (canon.starts_with(r) + && (r.ends_with('\\') || canon.as_bytes().get(r.len()) == Some(&b'\\'))) + }) +} + /// The longest of `roots` that is `canon` or a directory above it. pub fn covering_root<'a>(roots: &'a [String], canon: &str) -> Option<&'a str> { - roots + covering_roots(roots, canon).max_by_key(|r| r.len()) +} + +/// What keeps the NAME of each of `denies` denied. A deny is on the object, +/// so a directory above it that is renamed aside takes it along and leaves +/// the name free to be made anew. Each one is kept where it is, up to the +/// outermost of `roots`: a session that holds that one could move a root +/// nested in it. +fn pins(roots: &[String], denies: &[(String, SbAce)]) -> Vec<(String, SbAce)> { + denies .iter() - .map(String::as_str) - .filter(|r| { - canon == *r - || (canon.starts_with(r) - && (r.ends_with('\\') || canon.as_bytes().get(r.len()) == Some(&b'\\'))) + .filter(|(_, ace)| matches!(ace, SbAce::Deny(_))) + .filter_map(|(canon, _)| { + let outermost = covering_roots(roots, canon).min_by_key(|r| r.len())?; + Some(path_id::dirs_between(outermost, canon)) }) - .max_by_key(|r| r.len()) + .flatten() + .map(|dir| (dir, SbAce::DenyDelete)) + .collect() } /// The ACEs `canon` (now the object `cur`) is to carry, from the @@ -976,7 +995,7 @@ pub fn apply( } let all_roots = all_root_paths(conn)?; let mut per_session = Vec::new(); - for (canon, ace) in denies { + for (canon, ace) in denies.iter().chain(&pins(&all_roots, denies)) { let Some(root) = covering_root(&all_roots, canon) else { per_session.push((canon.clone(), *ace)); continue; @@ -1961,6 +1980,24 @@ mod tests { assert_eq!(c(r"\\?\C:\"), None); } + #[test] + fn pins_reach_the_outermost_root() { + let roots = vec![r"\\?\C:\a".to_owned(), r"\\?\C:\a\b\c".to_owned()]; + let deny = |p: &str| (p.to_owned(), SbAce::Deny(acl::DenyMask::WriteDeny)); + assert_eq!( + pins(&roots, &[deny(r"\\?\C:\a\b\c\.git\hooks")]), + [r"\\?\C:\a\b\c\.git", r"\\?\C:\a\b\c", r"\\?\C:\a\b"] + .map(|dir| (dir.to_owned(), SbAce::DenyDelete)), + ); + // Right under a root, under none, and what is no deny of an object. + let none = [ + deny(r"\\?\C:\a\f"), + deny(r"\\?\C:\x\y\f"), + (r"\\?\C:\a\b\f".to_owned(), SbAce::DenyFdc), + ]; + assert!(pins(&roots, &none).is_empty()); + } + fn marker_with(ever_enabled: bool, roots: &[&str]) -> Marker { Marker { ever_enabled, diff --git a/wip/579-e2e-additions.patch b/wip/579-e2e-additions.patch new file mode 100644 index 000000000..77202b39f --- /dev/null +++ b/wip/579-e2e-additions.patch @@ -0,0 +1,26 @@ +diff --git a/test/sandbox/winsrt.test.ts b/test/sandbox/winsrt.test.ts +index 331eae2..08fc913 100644 +--- a/test/sandbox/winsrt.test.ts ++++ b/test/sandbox/winsrt.test.ts +@@ -5888,6 +5888,10 @@ describe.if(isWindows)( + await run( + 'M1', + [ ++ // First, so that what follows would show it. ++ 'icacls .bashrc /grant *S-1-1-0:F', ++ 'icacls .git\\hooks /reset /t', ++ 'takeown /f .git\\config', + 'echo W>.bashrc', + 'echo W>.git\\config', + 'echo W>.git\\hooks\\pre-commit', +@@ -5928,7 +5932,9 @@ describe.if(isWindows)( + + await run( + 'M2b', +- 'echo X>made\\.git\\hooks\\pre-commit & echo X>.mcp.json & echo X>.git\\config', ++ // The sandbox account owns the first two. ++ 'icacls made\\.git\\hooks /reset /t & icacls .mcp.json /grant *S-1-1-0:F & ' + ++ 'echo X>made\\.git\\hooks\\pre-commit & echo X>.mcp.json & echo X>.git\\config', + ) + expect(contentOf('made/.git/hooks/pre-commit')).toBeUndefined() + expect(contentOf('.mcp.json')).toBe('W') diff --git a/wip/693tests/mutants.py b/wip/693tests/mutants.py new file mode 100644 index 000000000..5e5384e49 --- /dev/null +++ b/wip/693tests/mutants.py @@ -0,0 +1,191 @@ +"""Mutations of the source of sandbox-runtime PR 693. (id, file, old, new).""" + +RP = 'src/utils/real-path.ts' +SU = 'src/sandbox/sandbox-utils.ts' +LX = 'src/sandbox/linux-sandbox-utils.ts' +MAC = 'src/sandbox/macos-sandbox-utils.ts' +MGR = 'src/sandbox/sandbox-manager.ts' +GLOB = 'src/sandbox/read-deny-glob.ts' +CFG = 'src/sandbox/sandbox-config.ts' +C = 'vendor/seccomp-src/apply-seccomp.c' + +M = [] + + +def m(id_, file, old, new): + M.append((id_, file, old, new)) + + +# ---------------------------------------------------------------- real-path.ts +m('R01 EILSEQ error carries no path', RP, " code: 'EILSEQ',\n path: at,\n", " code: 'EILSEQ',\n") +m('R02 wayTo never calls watcher.left', RP, ' watcher.left?.(real)\n', '') +m('R03 wayTo calls left at the root too', RP, "if (name === '..' && real !== '') {", "if (name === '..') {") +m('R04 wayTo never calls watcher.link', RP, ' watcher.link?.(next, target)\n', '') +m('R06 never goes on past a missing name', RP, " names[1] !== '..' ||\n", " true ||\n") +m('R07 passed names are not recorded', RP, ' passed.push(`${real}/${names[0]}`)\n', '') +m('R08 goes on past a name it could not look for (EACCES)', RP, + " names[1] !== '..' ||\n (notThere as NodeJS.ErrnoException | undefined)?.code === 'EACCES'\n", + " names[1] !== '..'\n") +m('R09 rest keeps . and empty names', RP, 'return { real, rest: names, notThere, passed }', 'return { real, rest, notThere, passed }') +m('R10 only one missing name is passed', RP, 'const MAX_MISSING_NAMES_PASSED = 40', 'const MAX_MISSING_NAMES_PASSED = 0') +m('R11 no limit on missing names passed', RP, 'const MAX_MISSING_NAMES_PASSED = 40', 'const MAX_MISSING_NAMES_PASSED = 4000') +m('R12 39 links followed', RP, 'const MAX_LINKS = 40', 'const MAX_LINKS = 39') +m('R13 41 links followed', RP, 'const MAX_LINKS = 40', 'const MAX_LINKS = 41') +m('R14 too many missing names is EIO, not ELOOP', RP, " { code: 'ELOOP', path: folded },\n )\n}", " { code: 'EIO', path: folded },\n )\n}") +m('R15 U+FFFD in a target is always refused', RP, + "text.includes('\\uFFFD') && !ask(at, 'buffer').equals(Buffer.from(text))", "text.includes('\\uFFFD')") +m('R16 the watcher is not handed on past a missing name', RP, 'wayTo(onward, watcher)', 'wayTo(onward, passed.length ? undefined : watcher)') + +# ------------------------------------------------------------ followLinks +m('F01 path not resolved (relative, . and ..)', SU, " wayPastMissingNames(path.resolve(p), {", " wayPastMissingNames(p, {") +m('F02 no second look', SU, + " } catch {\n // Once more: a look can fail in passing (EIO, ESTALE), and what would\n // follow from that is a directory denied whole.\n try {\n way = walk()\n", + " } catch (first) {\n try {\n throw first\n") +m('F03 a link that is no text is not listed', SU, " if (code === 'EILSEQ' && at) links.set(at, '')\n", '') +m('F04 ELOOP is could-not-look', SU, " isAbsenceErrno(err) ||\n code === 'EILSEQ'", " (isAbsenceErrno(err) && code !== 'ELOOP') ||\n code === 'EILSEQ'") +m('F05 ENAMETOOLONG is could-not-look', SU, " isAbsenceErrno(err) ||\n code === 'EILSEQ'", " (isAbsenceErrno(err) && code !== 'ENAMETOOLONG') ||\n code === 'EILSEQ'") +m('F06 EILSEQ is could-not-look', SU, " code === 'EILSEQ' ||\n", '') +m('F07 NUL (ERR_INVALID_ARG_VALUE) is could-not-look', SU, " code === 'EILSEQ' ||\n code === 'ERR_INVALID_ARG_VALUE'", " code === 'EILSEQ'") +m('F08 unsearched from the path as written, not where it failed', SU, 'path.dirname(at ?? path.resolve(p))', 'path.dirname(path.resolve(p))') +m('F09 no unsearched where the error has no path', SU, 'path.dirname(at ?? path.resolve(p))', "path.dirname(at ?? '/')") +m('F10 the root can be unsearched (thrown)', SU, " ...(dir !== '/' && { unsearched: dir }),", " ...(!nothingCanBeThere && { unsearched: dir }),") +m('F11 EACCES is taken for not-there', SU, "?.code === 'EACCES'\n ? { links, left, passed,", "?.code === 'NEVER'\n ? { links, left, passed,") +m('F12 the root can be unsearched (EACCES)', SU, "...(real !== '' && { unsearched: real }) }", "unsearched: real }") +m('F13 end goes on past .. among missing names', SU, "(rest.includes('..') ? rest.slice(0, 1) : rest)", 'rest') +m('F14 end of the root is empty', SU, " ) || '/',\n }\n}", " ),\n }\n}") +m('F15 any failure is nothing-can-be-there', SU, " const nothingCanBeThere =\n isAbsenceErrno(err) ||", " const nothingCanBeThere =\n true ||\n isAbsenceErrno(err) ||") +m('F16 passed is dropped from the answer', SU, " left,\n passed,\n end:", " left,\n passed: [],\n end:") +m('F17 unsearched is the name, not its directory', SU, "path.dirname(at ?? path.resolve(p))", "(at ?? path.resolve(p))") + +# ------------------------------------------------------------ linux: which links +m('L01 held outside every write path too', LX, " isWithinAnyAllowedWritePath(path.dirname(link)) &&\n", '') +m('L02 kernel directories not left out', LX, " !KERNEL_TOP_LEVEL_DIRS.some(dir => isAtOrUnder(link, dir)) &&\n", '') +m('L03 hidden by tmpfs not left out', LX, " &&\n !isHiddenByTmpfs(path.dirname(link), 'writes and reads'),", ',') +m('L04 hidden asked of the link, not its directory', LX, "!isHiddenByTmpfs(path.dirname(link), 'writes and reads')", "!isHiddenByTmpfs(link, 'writes and reads')") +m('L05 no pins above held links', LX, " ...linksToHold.map(link => link.path),\n", '') +m('L06 no pins for left directories', LX, " ...[...leftOnTheWay].map(dir => `${dir}/.`),\n", '') +m('L07 a left directory itself is not pinned', LX, "[...leftOnTheWay].map(dir => `${dir}/.`)", "[...leftOnTheWay]") +m('L08 pins where nothing is held', LX, " ...(whyNotHeld === undefined\n ? [", " ...(true\n ? [") +m('L09 mandatory denies are not walked', LX, "const rawDenyPaths = denyPaths.map(asTaken)", + "const rawDenyPaths = denyPaths.map((p, i) => i < configuredDenyPaths.length ? asTaken(p) : normalizePathForSandbox(p, { literal: true }))") +m('L10 no stand-in for a directory not looked in', LX, " if (unsearched === undefined) return rawPath\n", " if (unsearched !== null) return rawPath\n") +m('L11 stand-in without a warning', LX, "; denying ${unsearched} whole instead`,\n { level: 'warn' },", "; denying ${unsearched} whole instead`,\n { level: 'info' },") +m('L12 passed names are not denied', LX, " rawDenyPaths.push(...alsoDenied.map(asTaken))\n", '') +m('L13 resolveSymlinkedDenyPath forgets passed', LX, " alsoDenied?.push(...way.passed)\n", '') +m('L14 left of a walk is not noted', LX, " for (const dir of left) leftOnTheWay.add(dir)\n", '') +m('L15 the helper is asked about with no link to hold', LX, "linksToHold.length > 0 ? whyNoLinkIsHeld() : undefined", "whyNoLinkIsHeld()") +m('L16 read denies are not walked', LX, " ...readDenyPlan().map(entry => entry.normalizedPath),\n ...(maskedFileBinds", " ...(maskedFileBinds") +m('L17 masked files are not walked', LX, " ...(maskedFileBinds ?? []).map(bind => bind.realPath),\n ]) {\n const { canonical, resolved }", " ]) {\n const { canonical, resolved }") +m('L18 a dangling read deny is not walked', LX, "if (!resolved || canonical !== name) noteLinksTo(name)", "if (canonical !== name) noteLinksTo(name)") +m('L19 every read deny is walked', LX, "if (!resolved || canonical !== name) noteLinksTo(name)", "noteLinksTo(name)") +m('L20 matchedLinks are not walked', LX, " readConfig?.matchedLinks?.forEach(noteLinksTo)\n", '') +m('L21 only the first link of a walk is noted', LX, "for (const [link, target] of links) linksToDenies.set(link, target)", "for (const [link, target] of [...links].slice(0, 1)) linksToDenies.set(link, target)") + +# ------------------------------------------------------------ linux: why not held +m('W01 no_helper not said', LX, " if (!helperInUse) return 'no_helper'\n", '') +m('W02 holdsLinks false is ignored', LX, "return config.holdsLinks ? undefined : 'told_no'", "return undefined") +m('W03 holdsLinks true is not taken', LX, " if (config?.holdsLinks !== undefined) {", " if (config?.holdsLinks === false) {") +m('W04 argv0 helper is looked for', LX, " const helper = config?.argv0\n ? null\n : getApplySeccompBinaryPath(config?.applyPath)", " const helper = getApplySeccompBinaryPath(config?.applyPath)") +m("W05 the caller's applyPath is read", LX, "if (helper === null || helper === config?.applyPath) return 'not_told'", "if (helper === null) return 'not_told'") +m('W06 any found helper is trusted', LX, "fs.readFileSync(helper).includes('--holds-links')\n ? undefined", "fs.readFileSync(helper).includes('')\n ? undefined") +m('W07 unreadable helper is older_helper', LX, " return 'no_answer'\n", " return 'older_helper'\n") +m('W08 the found helper is run', LX, "fs.readFileSync(helper).includes('--holds-links')\n ? undefined", "spawnSync(helper, ['--holds-links']).status === 0\n ? undefined") +m('W09 unreadable helper is trusted', LX, " return 'no_answer'\n", " return undefined\n") +m("W10 the caller's helper is run", LX, "if (helper === null || helper === config?.applyPath) return 'not_told'", + "if (helper === null) return 'not_told'\n if (helper === config?.applyPath) return spawnSync(helper, ['--holds-links']).status === 0 ? 'not_told' : 'not_told'") +m('W11 told_no helper is run', LX, "return config.holdsLinks ? undefined : 'told_no'", + "return config.holdsLinks ? undefined : (config.applyPath && spawnSync(config.applyPath, ['--holds-links']), 'told_no')") + +# ------------------------------------------------------------ linux: the words +m('H01 hold words are not quoted', LX, + " ? quote(\n linksToHold.flatMap(link => [\n '--hold-link',\n link.path,\n link.target,\n ]),\n )", + " ? linksToHold.flatMap(link => [\n '--hold-link',\n link.path,\n link.target,\n ]).join(' ')") +m('H02 target before path', LX, " link.path,\n link.target,\n", " link.target,\n link.path,\n") +m('H03 hold words after the command', LX, "if (holdWords !== '') applySeccompPrefix += `${holdWords} `", "if (holdWords !== '') applySeccompPrefix = `${holdWords} ${applySeccompPrefix}`") +m('H04 words handed where not held', LX, " const holdWords =\n whyNotHeld === undefined\n", " const holdWords =\n true\n") +m('H05 refusal does not count the hold words', LX, "holdWordsBytes: Buffer.byteLength(holdWords, 'utf8'),", "holdWordsBytes: 0,") +m('H06 refusal counts them wrong', LX, "holdWordsBytes: Buffer.byteLength(holdWords, 'utf8'),", "holdWordsBytes: Buffer.byteLength(holdWords, 'utf8') + 1,") +m('H07 refusal speaks of links where there are none', LX, " mounts.holdWordsBytes > 0\n", " mounts.holdWordsBytes >= 0\n") +m('H08 empty target dropped from the words', LX, " link.target,\n ]),", " ...(link.target ? [link.target] : []),\n ]),") + +# ------------------------------------------------------------ linux: the list +m('U01 a held link stays listed', LX, " unheldLinks.delete(link)\n", '') +m('U02 unheld links are not listed', LX, " unheldLinks.set(link, whyNotHeld)\n", '') +m('U03 forget forgets nothing', LX, " unheldLinks.clear()\n", '') +m('U04 reset() does not forget', MGR, " cleanupBwrapMountPoints({ force: true })\n forgetLinuxUnheldLinks()\n", " cleanupBwrapMountPoints({ force: true })\n") +m('U05 updateConfig() does not forget', MGR, " configInstalls++\n forgetLinuxUnheldLinks()\n", " configInstalls++\n") +m('U06 no warning for an unheld link', LX, "nothing holds it (${whyNotHeld})`,\n { level: 'warn' },", "nothing holds it (${whyNotHeld})`,\n { level: 'info' },") +m('U07 listed before the wrap can throw', LX, " const holdWords =\n whyNotHeld === undefined\n", + " for (const l of linksToHold) if (whyNotHeld) unheldLinks.set(l.path, whyNotHeld)\n const holdWords =\n whyNotHeld === undefined\n") +m('U08 the manager lists none', MGR, " getLinuxUnheldLinks,\n getConfig,", " getLinuxUnheldLinks: () => [],\n getConfig,") +m('U09 a wrap clears the list of earlier wraps', LX, " for (const { path: link } of linksToHold) {\n if (whyNotHeld === undefined) {", " unheldLinks.clear()\n for (const { path: link } of linksToHold) {\n if (whyNotHeld === undefined) {") +m('U10 warning without the reason', LX, "nothing holds it (${whyNotHeld})`", "nothing holds it`") + +# ------------------------------------------------------------ manager, glob, config +m('G01 wrap hands no matchedLinks', MGR, " matchedLinks: [...matchedLinks],\n", '') +m('G02 getFsReadConfig() hands no matchedLinks', MGR, " ...(matchedLinks.size > 0 && { matchedLinks: [...matchedLinks] }),\n", '') +m('G03 the expansion notes no matched link', GLOB, " if (walk.symlinks.has(candidate)) opts.matchedLinks?.add(candidate)\n", '') +m('G04 wrap does not ask the expansion for links', MGR, + " unlistableDenyDirs,\n { anchor, listings, matchedLinks },", " unlistableDenyDirs,\n { anchor, listings },") +m('G05 getFsReadConfig() does not ask the expansion', MGR, + " unlistableDenyDirs,\n { anchor, listings, matchedLinks },", " unlistableDenyDirs,\n { anchor, listings },") +m('G06 the schema has no holdsLinks', CFG, " holdsLinks: z\n .boolean()\n .optional()\n", " holdsLinksX: z\n .boolean()\n .optional()\n") +m('G07 every match is noted, link or not', GLOB, "if (walk.symlinks.has(candidate)) opts.matchedLinks?.add(candidate)", "opts.matchedLinks?.add(candidate)") + +# ------------------------------------------------------------ macOS +m('M01 no deny where the links lead', MAC, " ...(end === undefined ? [] : startingFrom(end, entry)),\n", '') +m('M02 unsearched directory not denied', MAC, " ...(unsearched === undefined ? [] : [unsearched]).map(toLiteralPathEntry),\n", '') +m('M03 passed names not denied', MAC, " ...passed.map(toLiteralPathEntry),\n", '') +m('M04 links not kept', MAC, "[...links.keys(), ...left]", "[...left]") +m('M05 left directories not kept', MAC, "[...links.keys(), ...left]", "[...links.keys()]") +m('M06 links among the devices kept', MAC, " if (isAtOrUnder(stays, '/dev')) continue\n", '') +m('M07 what holds a link is not kept', MAC, "for (const kept of [stays, ...getAncestorDirectories(stays)]) {", "for (const kept of [stays]) {") +m('M08 devices denied (both sides)', MAC, " canonical === '/' ||\n isAtOrUnder(canonical, '/dev')\n", " canonical === '/'\n") +m('M09 the root as a second spelling', MAC, " canonical === '/' ||\n isAtOrUnder(canonical, '/dev')\n", " isAtOrUnder(canonical, '/dev')\n") +m('M10 a pattern is not started from where links lead', MAC, "...(end === undefined ? [] : startingFrom(end, entry)),", "...(end === undefined || entry.glob ? [] : startingFrom(end, entry)),") +m('M11 devices hidden by a read deny', MAC, " return [entry, ...startingFrom(canonical, entry)]\n", + " return canonical === base ? [entry] : [entry, entry.glob ? { glob: true, path: canonical + entry.path.slice(base.length), anchor: canonical } : { glob: false, path: canonical }]\n") +m('M12 mandatory denies are not walked', MAC, + " ...macGetMandatoryDenyEntries(allowGitConfig),\n ...alsoReadAs(config.denyWithinAllow, 'deny'),\n ].map(entry => ({ entry, ...followLinks(entryBaseDir(entry)) }))", + " ...alsoReadAs(config.denyWithinAllow, 'deny'),\n ].map(entry => ({ entry, ...followLinks(entryBaseDir(entry)) })).concat(macGetMandatoryDenyEntries(allowGitConfig).map(entry => ({ entry, links: new Map(), left: new Set(), passed: [] })))") +m('M13 no read deny where the link leads', MAC, " return [entry, ...startingFrom(canonical, entry)]\n", " return [entry]\n") +m('M14 only the first link kept', MAC, "[...links.keys(), ...left]", "[...[...links.keys()].slice(0, 1), ...left]") +m('M15 the deny as written is dropped', MAC, "=> [\n entry,\n ...(end === undefined", "=> [\n ...(end === undefined") + +# ------------------------------------------------------------ the C helper +m('C01 not-a-link is not checked', C, ' if (!S_ISLNK(st.st_mode)) {\n die_holding(path, "not a symbolic link");\n }\n', '') +m('C02 a longer word passes (length not compared)', C, "(size_t)length != strlen(target) ||\n memcmp(says, target, (size_t)length) != 0)", "memcmp(says, target, (size_t)length) != 0)") +m('C03 a shorter word passes (prefix compared)', C, "(size_t)length != strlen(target) ||\n memcmp(says, target, (size_t)length) != 0)", "memcmp(says, target, strlen(target)) != 0)") +m('C04 bytes are not compared', C, "(size_t)length != strlen(target) ||\n memcmp(says, target, (size_t)length) != 0)", "(size_t)length != strlen(target))") +m('C05 an empty word is compared', C, "if (*target && ((size_t)length", "if (((size_t)length") +m('C06 the word is not compared at all', C, "if (*target && ((size_t)length", "if (0 && ((size_t)length") +m('C07 no remount', C, " if (mount(NULL, self, NULL, MS_REMOUNT | MS_BIND | flags, NULL) < 0) {\n die_holding(path, strerror(errno));\n }\n", '') +m('C08 remount without read-only', C, "unsigned long flags = MS_RDONLY | MS_NOSUID | MS_NODEV;", "unsigned long flags = MS_NOSUID | MS_NODEV;") +m('C09 remount without nosuid', C, "unsigned long flags = MS_RDONLY | MS_NOSUID | MS_NODEV;", "unsigned long flags = MS_RDONLY | MS_NODEV;") +m('C10 remount without nodev', C, "unsigned long flags = MS_RDONLY | MS_NOSUID | MS_NODEV;", "unsigned long flags = MS_RDONLY | MS_NOSUID;") +m('C11 noexec not given again', C, " if (fs.f_flag & ST_NOEXEC) flags |= MS_NOEXEC;\n", '') +m('C12 nosymfollow not given again', C, " if (fs.f_flag & ST_NOSYMFOLLOW) flags |= MS_NOSYMFOLLOW;\n", '') +m('C13 no user namespace after the holds', C, " hold_link(argv[3 * i + 2], argv[3 * i + 3]);\n }\n enter_user_namespace();\n", " hold_link(argv[3 * i + 2], argv[3 * i + 3]);\n }\n") +m('C14 --holds-links exits 2', C, 'strcmp(argv[1], "--holds-links") == 0) {\n return 0;', 'strcmp(argv[1], "--holds-links") == 0) {\n return 2;') +m('C15 with the capability, no link is held', C, "if (links > 0 || unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) {", "if (unshare(CLONE_NEWPID | CLONE_NEWNS) < 0) {") +m('C16 mounts not made private', C, ' if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) < 0) {\n die("apply-seccomp: mount(MS_PRIVATE)");\n }\n', '') +m('C17 only the first link is held', C, "for (int i = 0; i < links; i++) {\n hold_link", "for (int i = 0; i < 1; i++) {\n hold_link") +m('C18 no usage error without a command', C, " if (argc < 3 * links + 2) {", " if (argc < 2) {") +m('C19 a failed hold exits 0', C, " path, why);\n _exit(1);", " path, why);\n _exit(0);") +m('C20 a failed hold is only warned of', C, " path, why);\n _exit(1);", " path, why);") +m('C21 nothing is mounted', C, " if (mount(self, self, NULL, MS_BIND, NULL) < 0) {\n die_holding(path, strerror(errno));\n }\n close(fd);\n", " close(fd);\n return;\n") +m('C22 the refusal does not name holdsLinks', C, '%s: %s (seccomp.holdsLinks: false in the "\n "sandbox configuration holds no link)\\n"', '%s: %s\\n"') +m('C23 the link is followed when opened', C, " int fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC);\n if (fd < 0 || fstat", " int fd = open(path, O_PATH | O_CLOEXEC);\n if (fd < 0 || fstat") + +# ------------------------------------------------------------ added after the first run +HOLD_MAP = " .map(([link, target]) => ({ path: link, target }))\n" +m('L22 dangling links are not held', LX, HOLD_MAP, " .filter(([link]) => fs.existsSync(link))\n" + HOLD_MAP) +m('L23 links to directories are not held', LX, HOLD_MAP, " .filter(([link]) => !fs.statSync(link, { throwIfNoEntry: false })?.isDirectory())\n" + HOLD_MAP) +m('L24 links to devices are not held', LX, HOLD_MAP, " .filter(([, target]) => !target.startsWith('/dev'))\n" + HOLD_MAP) +m('L25 links to files are not held', LX, HOLD_MAP, " .filter(([link]) => !fs.statSync(link, { throwIfNoEntry: false })?.isFile())\n" + HOLD_MAP) +m('L26 the deny is walked as written, not normalized', LX, "const unsearched = noteLinksTo(rawPath)", "const unsearched = noteLinksTo(pathPattern)") +m('L27 held only where it leads into a write path', LX, HOLD_MAP, + " .filter(([link]) => {\n try {\n return isWithinAnyAllowedWritePath(fs.realpathSync(link))\n } catch {\n return true\n }\n })\n" + HOLD_MAP) +m('M16 /dev itself is denied', MAC, " isAtOrUnder(canonical, '/dev')\n", " canonical.startsWith('/dev/')\n") +m('U11 the reason of an earlier wrap stays', LX, " unheldLinks.set(link, whyNotHeld)\n", " if (!unheldLinks.has(link)) unheldLinks.set(link, whyNotHeld)\n") +m('C24 the remount follows the link', C, " fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC);\n if (fd < 0 || fstatvfs", " fd = open(path, O_PATH | O_CLOEXEC);\n if (fd < 0 || fstatvfs") diff --git a/wip/693tests/simp693tests.patch b/wip/693tests/simp693tests.patch new file mode 100644 index 000000000..a5b214b98 --- /dev/null +++ b/wip/693tests/simp693tests.patch @@ -0,0 +1,1634 @@ +diff --git a/test/sandbox/write-deny-through-links.test.ts b/test/sandbox/write-deny-through-links.test.ts +index 11bac92..02e1dd2 100644 +--- a/test/sandbox/write-deny-through-links.test.ts ++++ b/test/sandbox/write-deny-through-links.test.ts +@@ -8,35 +8,14 @@ import { + spyOn, + } from 'bun:test' + import * as fs from 'fs' +-import { spawnSync } from 'node:child_process' +-import { +- chmodSync, +- existsSync, +- lstatSync, +- mkdirSync, +- mkdtempSync, +- readFileSync, +- readlinkSync, +- realpathSync, +- rmSync, +- symlinkSync, +- writeFileSync, +-} from 'node:fs' ++import { spawnSync, type SpawnSyncReturns } from 'node:child_process' + import { homedir, tmpdir } from 'node:os' + import { join, relative } from 'node:path' +-import type { LinuxUnheldLinkReason } from '../../src/index.js' ++import type { LinuxUnheldLink } from '../../src/index.js' + import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +-import { +- cleanupBwrapMountPoints, +- forgetLinuxUnheldLinks, +- getLinuxUnheldLinks, +- wrapCommandWithSandboxLinux, +-} from '../../src/sandbox/linux-sandbox-utils.js' ++import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' + import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +-import { +- SandboxRuntimeConfigSchema, +- type SandboxRuntimeConfig, +-} from '../../src/sandbox/sandbox-config.js' ++import { SandboxRuntimeConfigSchema } from '../../src/sandbox/sandbox-config.js' + import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' + import { followLinks } from '../../src/sandbox/sandbox-utils.js' + import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +@@ -51,478 +30,294 @@ import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' + * Every link here is made on the host before the command is wrapped. + */ + +-let BASE: string +-let ROOT: string // the one write root +-let OUTSIDE: string // beside it, under no write root ++const CWD = process.cwd() ++// Where the temp dir really is: macOS names it through a link. ++const BASE = join(fs.realpathSync(tmpdir()), `write-deny-links-${process.pid}`) ++const ROOT = join(BASE, 'root') // the one write root ++const OUTSIDE = join(BASE, 'outside') // beside it, under no write root ++const HOLDER = join(ROOT, 'holder') ++const inRoot = (names: string[]): string[] => names.map(n => join(ROOT, n)) + +-/** Link name in ROOT -> what it says. */ ++/** Link name in ROOT -> what it says. A deny leads through every one. */ + const LINKS = { + name: 'conf/x', + 'holder/commands': '../shared/commands', + dangling: 'later', + way: 'real', ++ here: '.', + first: 'middle', + middle: 'conf/y', +- blank: '/dev/null', ++ up: 'in/to/../../real/secret', ++ past: 'lost/../gone/../name', ++ deep: 'void/deeper/../../real/secret', ++ far: 'holder/blank', ++ 'holder/blank': '/dev/null', + out: '/dev/stdout', +- 'other-link': 'conf/z', + } +-const FILES = [ +- 'conf/x', +- 'conf/y', +- 'conf/z', +- 'shared/commands/file', +- 'real/secret', +- 'beside', +-] +-const ABSENT = ['later', 'real/absent', 'shared/commands/new'] +-const DENIED = [ +- 'name', +- 'holder/commands', +- 'dangling', +- 'way/secret', +- 'way/absent', +- 'first', +- 'blank', +- 'out', ++type Link = keyof typeof LINKS ++const said = (names: Link[]): string[][] => names.map(n => [n, LINKS[n]]) ++/** ++ * A name in ROOT, the links on the way to it and where they lead; then the ++ * directories the way goes up from, and the names that are not there and that ++ * it goes up from all the same. ++ */ ++const WAYS: Array<[string, Link[], string, string[], string[]]> = [ ++ ['name', ['name'], 'conf/x', [], []], ++ ['holder/commands', ['holder/commands'], 'shared/commands', ['holder'], []], ++ ['dangling', ['dangling'], 'later', [], []], ++ ['way/secret', ['way'], 'real/secret', [], []], ++ ['way/absent', ['way'], 'real/absent', [], []], ++ // A link is named where it lies, whatever it was reached through. ++ ['here/name', ['here', 'name'], 'conf/x', [], []], ++ ['first', ['first', 'middle'], 'conf/y', [], []], ++ ['up', ['up'], 'real/secret', ['in/to', 'in'], []], ++ ['past', ['past', 'name'], 'conf/x', [], ['lost', 'gone']], ++ // Further on among names that are not there: it ends at the first. ++ ['deep', ['deep'], 'void', [], []], + ] ++const DENIED = inRoot([...WAYS.map(([name]) => name), 'far', 'out']) ++const FILES = ['conf/x', 'conf/y', 'conf/z', 'real/secret', 'beside'] ++const ABSENT = ['later', 'real/absent', 'shared/commands/new', 'lost', 'gone'] + /** EBUSY, as glibc words it ("Device or resource busy") or musl. */ + const BUSY = /resource busy/i + /** No mode keeps uid 0 from looking. */ + const modesCount = process.getuid?.() !== 0 ++const namespaces = isLinux && bwrapCanNamespace() + + beforeEach(() => { + if (isWindows) return +- // Where the temp dir really is: macOS names it through a link. +- BASE = realpathSync(mkdtempSync(join(tmpdir(), 'write-deny-links-'))) +- ROOT = join(BASE, 'root') +- OUTSIDE = join(BASE, 'outside') +- for (const dir of ['conf', 'shared/commands', 'real', 'holder']) { +- mkdirSync(join(ROOT, dir), { recursive: true }) ++ for (const dir of ['conf', 'shared/commands', 'real', 'holder', 'in/to']) { ++ fs.mkdirSync(join(ROOT, dir), { recursive: true }) + } +- mkdirSync(OUTSIDE) +- for (const file of FILES) writeFileSync(join(ROOT, file), 'original') ++ fs.mkdirSync(OUTSIDE) ++ for (const file of FILES) fs.writeFileSync(join(ROOT, file), 'original') + for (const [link, says] of Object.entries(LINKS)) { +- symlinkSync(says, join(ROOT, link)) ++ fs.symlinkSync(says, join(ROOT, link)) + } + }) + +-afterEach(() => { ++afterEach(async () => { + if (isWindows) return +- if (isLinux) cleanupBwrapMountPoints({ force: true }) +- chmodSync(join(ROOT, 'holder'), 0o755) +- rmSync(BASE, { recursive: true, force: true }) ++ mock.restore() ++ process.chdir(CWD) ++ // Removes what bubblewrap made to mount on, and empties the list of links. ++ await SandboxManager.reset() ++ fs.chmodSync(HOLDER, 0o755) ++ fs.rmSync(BASE, { recursive: true, force: true }) + }) + +-const inRoot = (names: string[]): string[] => names.map(n => join(ROOT, n)) ++/** `command`, run by a shell: unless told otherwise under no write root, so ++ * that nothing of the working directory's is protected or made. */ ++const run = (command: string, cwd = OUTSIDE): SpawnSyncReturns => ++ spawnSync(command, { shell: true, encoding: 'utf8', cwd, timeout: 30000 }) + +-/** `run` with ROOT for the working directory of this process. */ +-async function fromRoot(run: () => T | Promise): Promise { +- const cwd = process.cwd() +- process.chdir(ROOT) +- try { +- return await run() +- } finally { +- process.chdir(cwd) +- } +-} ++type Options = Parameters[0] & ++ Parameters[0] ++type Rest = Partial + +-/** An executable file in OUTSIDE that holds `script`. */ +-function program(name: string, script: string): string { +- const file = join(OUTSIDE, name) +- writeFileSync(file, `#!/bin/sh\n${script}\n`) +- chmodSync(file, 0o755) +- return file ++/** `denyWithinAllow` denied in ROOT, the write root beside two devices. */ ++function denying(denyWithinAllow: string[], rest?: Rest): Options { ++ const allowOnly = [ROOT, '/dev/null', '/dev/stdout'] ++ return { ++ command: 'echo started', ++ needsNetworkRestriction: false, ++ readConfig: { denyOnly: [] }, ++ writeConfig: { allowOnly, denyWithinAllow }, ++ ...rest, ++ } + } + + describe.if(!isWindows)('the links on the way to a path', () => { + /** What is said of `p`, a name in ROOT, with the names in ROOT again. */ + const followed = (p: string): object => { +- const { links, left, ...rest } = followLinks(join(ROOT, p)) +- const named = (at: string): string => relative(ROOT, at) +- return JSON.parse( +- JSON.stringify( +- { links: [...links], left: [...left], ...rest }, +- (_key, value: unknown) => +- typeof value === 'string' && value.startsWith(ROOT) +- ? named(value) +- : value, +- ), +- ) as object ++ const { links, left, ...rest } = followLinks(`${ROOT}/${p}`) ++ const told = JSON.stringify({ links: [...links], left: [...left], ...rest }) ++ return JSON.parse(told.split(`${ROOT}/`).join('')) as object + } +- const said = (...names: Array): string[][] => +- names.map(name => [name, LINKS[name]]) ++ const NONE = { left: [], passed: [] } + +- it.each<[string, Array, string]>([ +- ['beside', [], 'beside'], +- ['name', ['name'], 'conf/x'], +- ['holder/commands/file', ['holder/commands'], 'shared/commands/file'], +- ['dangling', ['dangling'], 'later'], +- ['way/absent/deeper', ['way'], 'real/absent/deeper'], +- ['first', ['first', 'middle'], 'conf/y'], +- ['absent/name', [], 'absent/name'], +- ['beside/name', [], 'beside/name'], +- ['./way/./secret/', ['way'], 'real/secret'], +- // Folded in text, as the path it is given always is. +- ['holder/commands/../name', [], 'holder/name'], +- ])('%s: %p, ending at %s', (p, links, end) => { +- expect(followed(p)).toMatchObject({ +- links: said(...links), +- passed: [], +- end, +- }) ++ it.each<(typeof WAYS)[number]>([ ++ ...WAYS, ++ ['beside', [], 'beside', [], []], ++ ['absent/name', [], 'absent/name', [], []], ++ ['beside/name', [], 'beside/name', [], []], ++ ['way/absent/deeper', ['way'], 'real/absent/deeper', [], []], ++ // Folded in text, as everywhere a configured name is read. ++ ['./way/./secret/', ['way'], 'real/secret', [], []], ++ ['holder/commands/../name', [], 'holder/name', [], []], ++ ])('%s: %p, ending at %s', (p, links, end, left, passed) => { ++ expect(followed(p)).toEqual({ links: said(links), left, passed, end }) + }) + + it.each([ +- [ +- 'a directory', +- 'shared/commands/../../real/secret', +- { left: ['shared/commands', 'shared'], passed: [], end: 'real/secret' }, +- ], +- [ +- 'a link to one, whose parent it is the parent of', +- 'holder/commands/../made', +- { +- links: [...said('holder/commands')], +- left: ['holder', 'shared/commands'], +- passed: [], +- end: 'shared/made', +- }, +- ], +- [ +- 'a name that is not there', +- 'missing/../gone/../real/secret', +- { left: [], passed: ['missing', 'gone'], end: 'real/secret' }, +- ], +- [ +- 'one further on among names that are not there, where it ends at the first', +- 'missing/deeper/../../real/secret', +- { left: [], passed: [], end: 'missing' }, +- ], +- ])('a target that goes up again from %s: %s', (_what, target, told) => { +- symlinkSync(target, join(ROOT, 'up')) +- expect(followed('up')).toEqual({ +- ...told, +- links: [['up', target], ...('links' in told ? told.links : [])], +- }) +- }) +- +- it('takes a relative path from the working directory', async () => { +- expect(await fromRoot(() => followLinks('name').end)).toBe( +- join(ROOT, 'conf', 'x'), +- ) +- }) +- +- it('names a link where it lies, whatever it was reached through', () => { +- symlinkSync(ROOT, join(OUTSIDE, 'to-root')) +- expect([...followLinks(join(OUTSIDE, 'to-root', 'name')).links]).toEqual([ +- [join(OUTSIDE, 'to-root'), ROOT], +- [join(ROOT, 'name'), LINKS.name], +- ]) +- }) +- +- it('reads a target that holds U+FFFD itself like any other', () => { +- symlinkSync('x�y', join(ROOT, 'odd')) +- expect(followed('odd/file')).toMatchObject({ +- links: [['odd', 'x�y']], +- end: 'x�y/file', +- }) ++ ['longer than a name can be', 'a'.repeat(300)], ++ ['with a NUL byte', 'nul\0byte'], ++ ])('a name %s: nothing can be there, so it has looked', (_what, name) => { ++ expect(followed(`way/${name}`)).toEqual({ links: said(['way']), ...NONE }) + }) + + // Not every file system takes such a target. + it.if(isLinux)('has no end past a target that is no text', () => { +- symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) +- expect(followed('bytes/file')).toEqual({ +- links: [['bytes', '']], +- left: [], +- passed: [], +- }) ++ fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'raw')) ++ expect(followed('raw/file')).toEqual({ links: [['raw', '']], ...NONE }) + }) + +- it('follows as many links as the Linux kernel, and has no end past them', () => { +- symlinkSync('beside', join(ROOT, 'hop-1')) +- for (let i = 2; i <= 41; i++) { +- symlinkSync(`hop-${i - 1}`, join(ROOT, `hop-${i}`)) ++ it('follows 40 links, and has no end past them, where the kernel gives up too', () => { ++ for (let i = 1; i <= 41; i++) { ++ fs.symlinkSync(i > 1 ? `hop-${i - 1}` : 'beside', join(ROOT, `hop-${i}`)) + } + expect(followed('hop-40')).toMatchObject({ end: 'beside' }) +- expect(followed('hop-41')).not.toHaveProperty('end') +- expect(followed('hop-41')).not.toHaveProperty('unsearched') +- // macOS gives up sooner, at 32: what is denied past that is denied in vain. +- if (isLinux) { +- expect(() => readFileSync(join(ROOT, 'hop-40'))).not.toThrow() +- } +- expect(() => readFileSync(join(ROOT, 'hop-41'))).toThrow('ELOOP') ++ expect(Object.keys(followed('hop-41'))).toEqual(['links', 'left', 'passed']) ++ expect(() => fs.readFileSync(join(ROOT, 'hop-41'))).toThrow('ELOOP') + }) + +- it.each([ +- ['longer than a name can be', 'a'.repeat(300)], +- ['with a NUL byte', 'nul\0byte'], +- ])('a name %s: nothing can be there, so it has looked', (_what, name) => { +- expect(followed(`way/${name}`)).toEqual({ +- links: said('way'), +- left: [], +- passed: [], +- }) ++ it.if(modesCount)('says which directory it could not look in', () => { ++ fs.chmodSync(HOLDER, 0) ++ fs.symlinkSync('holder/commands/../made', join(ROOT, 'via')) ++ expect(followed('via')).toMatchObject({ ...NONE, unsearched: 'holder' }) + }) + +- it.if(modesCount)( +- 'stops at a directory it cannot look in, and says which', +- () => { +- chmodSync(join(ROOT, 'holder'), 0) +- symlinkSync('holder/commands', join(ROOT, 'to-holder')) +- expect(followed('to-holder/file')).toEqual({ +- links: [['to-holder', 'holder/commands']], +- left: [], +- passed: [], +- unsearched: 'holder', +- }) +- }, +- ) +- +- describe('a look that fails', () => { +- afterEach(() => mock.restore()) +- const failing = (code: string, path?: string): Error => +- Object.assign(new Error(code), { code, path }) ++ const failing = (at?: fs.PathLike): never => { ++ throw Object.assign(new Error('EIO'), { code: 'EIO', path: at }) ++ } + +- it.each(['EIO', 'ESTALE', 'EINVAL'])( +- 'with %s both times: the directory of what it looked at', +- code => { +- spyOn(fs, 'readlinkSync').mockImplementation(at => { +- throw failing(code, String(at)) +- }) +- expect(followed('holder/commands/file')).toMatchObject({ +- unsearched: 'holder', +- }) +- }, +- ) ++ it('looks again where a look fails', () => { ++ spyOn(fs, 'readlinkSync').mockImplementationOnce(failing) ++ expect(followed('name')).toMatchObject({ end: 'conf/x' }) ++ }) + +- it('once: it looks again', () => { +- spyOn(fs, 'readlinkSync').mockImplementationOnce(at => { +- throw failing('EIO', String(at)) +- }) +- expect(followed('name')).toMatchObject({ end: 'conf/x' }) +- }) ++ it('where that fails too: the directory of what it looked at', () => { ++ spyOn(fs, 'readlinkSync').mockImplementation(failing) ++ expect(followed('way/secret')).toMatchObject({ unsearched: ROOT }) ++ }) + +- it('and does not say where: the directory of the path as written', () => { +- spyOn(fs, 'readlinkSync').mockImplementation(() => { +- throw failing('EIO') +- }) +- expect(followed('way/secret')).toMatchObject({ unsearched: 'way' }) +- }) ++ it('and does not say where: the directory of the path as written', () => { ++ spyOn(fs, 'readlinkSync').mockImplementation(() => failing()) ++ expect(followed('way/secret')).toMatchObject({ unsearched: 'way' }) ++ }) + +- it('in the root: no directory, which would stand in for everything', () => { +- spyOn(fs, 'lstatSync').mockImplementation(at => { +- throw failing('EIO', String(at)) +- }) +- expect(followLinks(ROOT)).toEqual({ +- links: new Map(), +- left: new Set(), +- passed: [], +- }) +- }) ++ it('in the root: no directory, which would stand in for everything', () => { ++ spyOn(fs, 'lstatSync').mockImplementation(failing) ++ expect(followed('name')).toEqual({ links: [], ...NONE }) + }) + }) + +-describe.if((isLinux && bwrapCanNamespace()) || isMacOS)( +- 'A write deny on a path with a link on the way', +- () => { +- /** What `attempt` came to, run in ROOT with every one of `denied` denied. */ +- async function outcomeOf( +- attempt: string, +- denied = DENIED, +- ): Promise { +- const options = { +- command: `cd ${ROOT} && echo started && if ( ${attempt} ); then echo DONE; else echo refused; fi`, +- needsNetworkRestriction: false, +- // Linux mounts a read deny where it resolves, under the /dev it then +- // makes anew, and bubblewrap before 0.5.0 takes no device to mount on. +- readConfig: { denyOnly: isMacOS ? inRoot(['blank']) : [] }, +- writeConfig: { +- allowOnly: [ROOT, '/dev/null', '/dev/stdout'], +- denyWithinAllow: inRoot(denied), +- }, +- } +- const wrapped = isLinux +- ? await wrapCommandWithSandboxLinux(options) +- : wrapCommandWithSandboxMacOS(options) +- const result = spawnSync(wrapped, { +- shell: true, +- encoding: 'utf8', +- timeout: 30000, +- // Under no write root, so nothing of the working directory's is +- // protected or made. +- cwd: OUTSIDE, +- }) +- const said = `${result.stdout}${result.stderr}` +- expect(said).toContain('started') +- if (isLinux) cleanupBwrapMountPoints({ force: true }) +- return said.includes('DONE') ? 'DONE' : 'refused' +- } +- +- it.each([ +- ['a write through the link', 'echo changed >> name'], +- [ +- 'a write to what it leads to, by its own path', +- 'echo changed >> conf/x', +- ], +- ['removing the link', 'rm name'], +- ['renaming a file over the link', 'echo new > made && mv -f made name'], +- ['renaming the link away', 'mv name aside'], +- ['making the link anew', 'ln -sfn beside name'], +- ['removing what it leads to', 'rm conf/x'], +- ['renaming the directory of what it leads to', 'mv conf aside'], +- [ +- 'a create beneath a link to a directory', +- 'echo n > holder/commands/new', +- ], +- ['the same by its own path', 'echo new > shared/commands/new'], +- ['removing a link to a directory', 'rm holder/commands'], +- ['renaming the directory that holds the link', 'mv holder aside'], +- ['a create through a dangling link', 'echo new > dangling'], +- ['a create of what it leads to, by its own path', 'echo new > later'], +- ['removing a dangling link', 'rm dangling'], +- ['a write beneath a link on the way', 'echo changed >> way/secret'], +- ['the same by its own path', 'echo changed >> real/secret'], +- ['a create beneath a link on the way', 'echo new > way/absent'], +- ['the same by its own path', 'echo new > real/absent'], +- ['removing a link on the way', 'rm way'], +- ['a write through two links', 'echo changed >> first'], +- ['removing the second of two links', 'rm middle'], +- ['renaming a file over it', 'echo new > made && mv -f made middle'], +- ['removing a link to a device', 'rm blank'], +- ['making it anew', 'ln -sfn beside out'], +- ])('refuses %s: %s', async (_what, attempt) => { +- expect(await outcomeOf(attempt)).toBe('refused') +- +- for (const [link, says] of Object.entries(LINKS)) { +- expect(lstatSync(join(ROOT, link)).isSymbolicLink()).toBe(true) +- expect(readlinkSync(join(ROOT, link))).toBe(says) +- } +- for (const file of FILES) { +- expect(readFileSync(join(ROOT, file), 'utf8')).toBe('original') +- } +- for (const name of ABSENT) +- expect(existsSync(join(ROOT, name))).toBe(false) +- }) +- +- it.each([ +- ['a write to a file beside the link', 'echo changed >> beside'], +- ['removing it', 'rm beside'], +- ['a create beside the link', 'echo new > new && echo new > holder/new'], +- ['a write beside what the link leads to', 'echo changed >> conf/z'], +- ['replacing a link no deny leads through', 'ln -sfn beside other-link'], +- [ +- 'reading the link, and through it', +- 'test "$(readlink name)$(cat name)$(ls holder/commands)" = conf/xoriginalfile', +- ], +- ['a write to a device a denied name leads to', 'echo x > /dev/null'], +- ['a read of it', 'cat /dev/null'], +- ])('allows %s: %s', async (_what, attempt) => { +- expect(await outcomeOf(attempt)).toBe('DONE') ++describe.if(namespaces || isMacOS)('a deny with a link on the way', () => { ++ /** What `attempt` came to, run in ROOT with every one of `denied` denied. */ ++ async function outcomeOf(attempt: string, denied = DENIED): Promise { ++ const options = denying(denied, { ++ command: `cd ${ROOT} && if ( ${attempt} ); then echo DONE; else echo refused; fi`, ++ // Linux mounts a read deny where it resolves, under the /dev it then ++ // makes anew, and bubblewrap before 0.5.0 takes no device to mount on. ++ readConfig: { denyOnly: isMacOS ? inRoot(['far']) : [] }, + }) +- +- it.each([ +- ['refused', 'rm .bashrc'], +- ['refused', 'echo changed >> conf/x'], +- ['refused', 'rm .claude/commands'], +- ['refused', 'echo new > shared/commands/new'], +- ['refused', 'rm .zshrc'], +- ['DONE', 'echo x > /dev/null'], +- ])( +- 'the same of the names denied in every working directory: %s: %s', +- async (outcome, attempt) => { +- mkdirSync(join(ROOT, '.claude')) +- symlinkSync('conf/x', join(ROOT, '.bashrc')) +- symlinkSync('/dev/null', join(ROOT, '.zshrc')) +- symlinkSync('../shared/commands', join(ROOT, '.claude', 'commands')) +- expect(await fromRoot(() => outcomeOf(attempt, []))).toBe(outcome) +- }, ++ const { stdout } = run( ++ isLinux ++ ? await wrapCommandWithSandboxLinux(options) ++ : wrapCommandWithSandboxMacOS(options), + ) ++ SandboxManager.cleanupAfterCommand() ++ return stdout.trim() ++ } + +- it.each([ +- [ +- 'a directory', +- 'shared/commands/../../real/secret', +- 'mv shared/commands aside', +- ], +- ['a directory', 'shared/commands/../../real/secret', 'mv shared aside'], +- ['a name that is not there', 'missing/../real/secret', 'mkdir missing'], +- [ +- 'a name that is not there', +- 'missing/../real/secret', +- 'ln -s conf missing', +- ], +- ])( +- 'where a link goes up again from %s (%s), refuses: %s', +- async (_what, target, attempt) => { +- symlinkSync(target, join(ROOT, 'up')) +- expect(await outcomeOf(attempt, ['up'])).toBe('refused') +- expect(await outcomeOf('echo changed >> up', ['up'])).toBe('refused') +- }, +- ) ++ it.each([ ++ ...Object.keys(LINKS).flatMap(link => [ ++ `rm ${link}`, ++ `mv ${link} aside`, ++ `ln -sfn beside ${link}`, ++ ]), ++ ...['name', 'dangling', 'middle'].map( ++ link => `echo new > made && mv -f made ${link}`, ++ ), ++ // A write or a create through the links, and by the path they lead to. ++ ...[...new Set(WAYS.flatMap(([name, , end]) => [name, end]))].map( ++ at => `echo changed >> ${at} || echo new > ${at}/new`, ++ ), ++ 'rm conf/x', ++ // The directories above a link and above what it leads to, those the way ++ // goes up from, and the names that are not there. ++ 'mv holder aside', ++ 'mv conf aside', ++ 'mv in/to aside', ++ 'mv in aside', ++ 'mkdir lost', ++ 'ln -s conf gone', ++ ])('refuses: %s', async attempt => { ++ expect(await outcomeOf(attempt)).toBe('refused') ++ ++ for (const [link, says] of Object.entries(LINKS)) { ++ expect(fs.readlinkSync(join(ROOT, link))).toBe(says) ++ } ++ for (const file of FILES) { ++ expect(fs.readFileSync(join(ROOT, file), 'utf8')).toBe('original') ++ } ++ expect(inRoot(ABSENT).filter(fs.existsSync)).toEqual([]) ++ }) + +- it.if(modesCount)( +- 'keeps a directory that cannot be looked in as it is, mode and all', +- async () => { +- expect(await outcomeOf('chmod 000 holder')).toBe('DONE') +- expect( +- await outcomeOf( +- 'chmod 755 holder; rm holder/commands || rm -r holder', +- ), +- ).toBe('refused') ++ it.each([ ++ 'echo changed >> beside && rm beside', ++ 'echo new > new && echo new > holder/new', ++ 'echo changed >> conf/z', ++ 'ln -s conf/z free && ln -sfn beside free', ++ 'test "$(readlink name)$(cat name way/secret)" = conf/xoriginaloriginal', ++ 'echo x > /dev/null && cat /dev/null', ++ ])('allows: %s', async attempt => { ++ expect(await outcomeOf(attempt)).toBe('DONE') ++ }) ++ ++ it.each(['rm .bashrc', 'echo changed >> conf/z', 'rm .claude/commands'])( ++ 'the same of the names denied in every working directory: %s', ++ async attempt => { ++ fs.mkdirSync(join(ROOT, '.claude')) ++ fs.symlinkSync('conf/z', join(ROOT, '.bashrc')) ++ fs.symlinkSync('../shared/commands', join(ROOT, '.claude', 'commands')) ++ process.chdir(ROOT) ++ expect(await outcomeOf(attempt, [])).toBe('refused') ++ }, ++ ) + +- expect(lstatSync(join(ROOT, 'holder')).mode & 0o777).toBe(0) +- chmodSync(join(ROOT, 'holder'), 0o755) +- expect(readlinkSync(join(ROOT, 'holder', 'commands'))).toBe( +- LINKS['holder/commands'], +- ) +- }, +- ) +- }, +-) ++ it.if(modesCount)('a directory it cannot look in is kept whole', async () => { ++ expect(await outcomeOf('chmod 000 holder')).toBe('DONE') ++ const undoing = 'chmod 755 holder; rm holder/commands || rm -r holder' ++ expect(await outcomeOf(undoing)).toBe('refused') ++ expect(fs.lstatSync(HOLDER).mode & 0o777).toBe(0) ++ fs.chmodSync(HOLDER, 0o755) ++ expect(fs.readdirSync(HOLDER).sort()).toEqual(['blank', 'commands']) ++ }) ++}) + + // Profile text only, so it runs on every POSIX host. + describe.if(!isWindows)('Seatbelt profile', () => { +- /** The filters of the rule that denies `operations`, one to a line. */ +- const deniedBy = (operations: string, denyWithinAllow: string[]): string => { +- const profile = wrapCommandWithSandboxMacOS({ +- command: 'true', +- needsNetworkRestriction: false, +- readConfig: undefined, +- writeConfig: { allowOnly: [ROOT], denyWithinAllow }, +- }) +- const at = profile.indexOf(`(deny ${operations}\n`) ++ /** The filters of the rule that denies `ops`, one to a line. */ ++ function deniedBy(ops: string, write: string[], read: string[] = []): string { ++ const profile = wrapCommandWithSandboxMacOS( ++ denying(write, { readConfig: { denyOnly: read } }), ++ ) ++ const at = profile.indexOf(`(deny ${ops}\n`) + expect(at).toBeGreaterThan(-1) + return profile.slice(at, profile.indexOf('(with message', at)) + } + const KEPT = 'file-write-unlink file-write-create' +- const subpath = (name: string): string => +- ` (subpath "${join(ROOT, name)}")\n` +- const literal = (name: string): string => +- ` (literal "${join(ROOT, name)}")\n` ++ const subpath = (name: string): string => `(subpath "${join(ROOT, name)}")\n` ++ const literal = (name: string): string => `(literal "${join(ROOT, name)}")\n` + +- it.each([ +- ['name', 'conf/x'], +- ['holder/commands', 'shared/commands'], +- ['dangling', 'later'], +- ['way/secret', 'real/secret'], +- ['way/absent', 'real/absent'], +- ['first', 'conf/y'], +- ])('a deny on %s is one on %s too', (written, end) => { +- const denies = deniedBy('file-write*', inRoot([written])) +- expect(denies).toContain(subpath(written)) +- expect(denies).toContain(subpath(end)) ++ it.each(WAYS)('%s keeps %p, denies %s', (name, links, end, left, passed) => { ++ const denies = deniedBy('file-write*', inRoot([name])) ++ for (const at of [name, end, ...passed]) { ++ expect(denies).toContain(subpath(at)) ++ } ++ const kept = deniedBy(KEPT, inRoot([name])) ++ for (const at of [...links, ...left]) expect(kept).toContain(literal(at)) + }) + +- it('so is one on a name denied in every working directory', async () => { +- symlinkSync('conf/x', join(ROOT, '.bashrc')) +- expect(await fromRoot(() => deniedBy('file-write*', []))).toContain( +- subpath('conf/x'), +- ) ++ it('so is one on a name denied in every working directory', () => { ++ fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) ++ process.chdir(ROOT) ++ expect(deniedBy('file-write*', [])).toContain(subpath('conf/x')) + }) + + it('a pattern deny starts from both spellings of its directory', () => { +- const denies = deniedBy('file-write*', [join(ROOT, 'way', '*.key')]) ++ const denies = deniedBy('file-write*', inRoot(['way/*.key'])) + const matches = (file: string): boolean => + [...denies.matchAll(/^ {2}\(regex (".*")\)$/gm)].some(([, text]) => + new RegExp(JSON.parse(text!) as string).test(join(ROOT, file)), +@@ -532,656 +327,345 @@ describe.if(!isWindows)('Seatbelt profile', () => { + expect(matches('conf/a.key')).toBe(false) + }) + +- it('every link on the way, and what holds it, is kept in place', () => { +- mkdirSync(join(ROOT, 'elsewhere')) +- symlinkSync('../conf/x', join(ROOT, 'elsewhere', 'hop')) +- symlinkSync('elsewhere/hop', join(ROOT, 'far')) +- const kept = deniedBy(KEPT, inRoot(['first', 'far'])) +- for (const name of ['middle', 'elsewhere/hop', 'elsewhere', 'conf']) { ++ it('nothing among the devices is denied or kept; the links to them are kept, and what holds one', () => { ++ fs.symlinkSync('/dev', join(ROOT, 'devices')) ++ const denied = inRoot(['far', 'out', 'devices']) ++ const kept = deniedBy(KEPT, denied) ++ expect(deniedBy('file-write*', denied) + kept).not.toContain('"/dev') ++ for (const name of ['far', 'holder/blank', 'holder', 'out', 'devices']) { + expect(kept).toContain(literal(name)) + } +- expect(kept).not.toContain(join(ROOT, 'other-link')) +- }) +- +- it('nothing among the devices is denied or kept, and the link to it is kept', () => { +- symlinkSync('/dev', join(ROOT, 'devices')) +- const denied = inRoot(['blank', 'out', 'devices']) +- for (const operations of ['file-write*', KEPT]) { +- expect(deniedBy(operations, denied)).not.toContain('"/dev') +- } +- for (const name of ['blank', 'out', 'devices']) { +- expect(deniedBy(KEPT, denied)).toContain(literal(name)) +- } + }) + + it('nor is a device hidden by a read deny on a link to it', () => { +- const profile = wrapCommandWithSandboxMacOS({ +- command: 'true', +- needsNetworkRestriction: false, +- readConfig: { denyOnly: inRoot(['blank', 'name']) }, +- writeConfig: undefined, +- }) +- const at = profile.indexOf('(deny file-read*\n') +- const denies = profile.slice(at, profile.indexOf('(with message', at)) ++ const denies = deniedBy('file-read*', [], inRoot(['far', 'name'])) + expect(denies).toContain(subpath('conf/x')) + expect(denies).not.toContain('"/dev') + }) + +- it.if(modesCount)( +- 'a directory that cannot be looked in is denied whole', +- () => { +- chmodSync(join(ROOT, 'holder'), 0) +- expect(deniedBy('file-write*', inRoot(['holder/commands']))).toContain( +- subpath('holder'), +- ) +- expect(deniedBy('file-write*', inRoot(['name']))).not.toContain( +- subpath('holder'), +- ) +- }, +- ) ++ it.if(modesCount)('a directory it cannot look in is denied whole', () => { ++ fs.chmodSync(HOLDER, 0) ++ expect(deniedBy('file-write*', inRoot(['holder/commands']))).toContain( ++ subpath('holder'), ++ ) ++ }) + }) + +-type LinuxOptions = Parameters[0] +- +-/** `denyWithinAllow` denied in ROOT, wrapped for Linux. */ +-const wrapLinux = ( +- denyWithinAllow: string[], +- rest: Partial = {}, +-): Promise => +- wrapCommandWithSandboxLinux({ +- command: 'echo started', +- needsNetworkRestriction: false, +- readConfig: { denyOnly: [] }, +- writeConfig: { allowOnly: [ROOT], denyWithinAllow }, +- ...rest, +- }) ++function wrapLinux(denied: string[], rest?: Rest): Promise { ++ return wrapCommandWithSandboxLinux(denying(denied, rest)) ++} + +-const stdoutOf = (wrapped: string): string => +- spawnSync(wrapped, { +- shell: true, +- encoding: 'utf8', +- cwd: OUTSIDE, +- timeout: 30000, +- }).stdout ++/** An executable file in OUTSIDE that holds `script`. */ ++function program(name: string, script: string): string { ++ const file = join(OUTSIDE, name) ++ fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) ++ return file ++} + + /** +- * The words a wrap hands the seccomp helper before the command, as [link, +- * target] pairs. Read by the shells themselves, through every level of ++ * The words a wrap hands the seccomp helper before the command, as [link in ++ * ROOT, target] pairs. Read by the shells themselves, through every level of + * quoting, and with no namespace made: a stand-in for bubblewrap runs what +- * follows `--`, and one for the helper prints its words and runs nothing. ++ * follows `--`, three words, and one for the helper only prints its words. + */ +-async function heldBy( +- denyWithinAllow: string[], +- rest: Partial = {}, +-): Promise> { +- const words = stdoutOf( +- await wrapLinux(denyWithinAllow, { +- bwrapPath: program( +- 'bwrap-stand-in', +- 'while [ "$1" != -- ]; do shift; done; shift; exec "$@"', +- ), +- seccompConfig: { +- applyPath: program('helper-stand-in', `printf '%s\\0' "$@"`), +- holdsLinks: true, +- }, +- ...rest, +- }), +- ).split('\0') +- const held: Array<[string, string]> = [] ++async function heldBy(denied: string[], rest?: Rest): Promise { ++ const bwrapPath = program('bwrap-stand-in', 'shift $(($# - 3)); exec "$@"') ++ const applyPath = program('helper-stand-in', `printf '%s\\0' "$@"`) ++ const seccompConfig = { applyPath, holdsLinks: true } ++ const wrapped = await wrapLinux(denied, { bwrapPath, seccompConfig, ...rest }) ++ const words = run(wrapped).stdout.split('\0') ++ const held: string[][] = [] + for (let i = 0; words[i] === '--hold-link'; i += 3) { +- held.push([words[i + 1]!, words[i + 2]!]) ++ held.push([relative(ROOT, words[i + 1]!), words[i + 2]!]) + } + expect(words.slice(3 * held.length + 1)).toEqual(['-c', 'echo started', '']) + return held.sort() + } +-/** LINKS' names as {@link heldBy} gives them. */ +-const pairs = (...names: Array): Array<[string, string]> => +- names.sort().map(name => [join(ROOT, name), LINKS[name]]) + + describe.if(isLinux)('what the seccomp helper is asked to hold', () => { +- it('each link once, where it lies, with what it says', async () => { +- symlinkSync(ROOT, join(OUTSIDE, 'to-root')) +- expect( +- await heldBy([ +- ...inRoot(['first', 'way/secret', 'way/absent']), +- join(OUTSIDE, 'to-root', 'middle'), +- ]), +- ).toEqual(pairs('first', 'middle', 'way')) +- }) +- +- it('whatever characters the name and the target hold', async () => { +- const odd = [ +- "sp ace'quote", +- 'new\nline', +- '$(echo x)`y`', +- '--hold-link', +- '!', +- ] +- for (const name of odd) symlinkSync(`to ${name}`, join(ROOT, name)) +- expect(await heldBy(inRoot(odd))).toEqual( +- odd.sort().map(name => [join(ROOT, name), `to ${name}`]), ++ it('each link once, with what it says; none for a path with no link on the way, or that lies where the command cannot write', async () => { ++ fs.symlinkSync(join(ROOT, 'conf', 'x'), join(OUTSIDE, 'in')) ++ const none = [...inRoot(['beside', 'absent']), join(OUTSIDE, 'in')] ++ expect(await heldBy([...DENIED, ...none])).toEqual( ++ Object.entries(LINKS).sort(), + ) + }) + +- it('with no word on a target that is no text', async () => { +- symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) +- expect(await heldBy(inRoot(['bytes']))).toEqual([[join(ROOT, 'bytes'), '']]) +- }) +- +- it('for a deny written from the home or the working directory', async () => { +- const fromHome = `~/${relative(homedir(), join(ROOT, 'way', 'x'))}` +- expect(await fromRoot(() => heldBy(['name', fromHome]))).toEqual( +- pairs('name', 'way'), ++ it('whatever characters the name and the target hold, U+FFFD too, with no word on a target that is no text', async () => { ++ const odd = ["sp ace'quote", 'new\nline', '$(echo x)`y`', 'x�y'] ++ for (const name of odd) fs.symlinkSync(`to ${name}`, join(ROOT, name)) ++ fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) ++ expect(await heldBy(inRoot([...odd, 'bytes']))).toEqual( ++ [...odd.map(name => [name, `to ${name}`]), ['bytes', '']].sort(), + ) + }) + +- it('for a name denied in every working directory', async () => { +- symlinkSync('conf/x', join(ROOT, '.bashrc')) +- expect(await fromRoot(() => heldBy([]))).toEqual([ +- [join(ROOT, '.bashrc'), 'conf/x'], +- ]) +- }) +- +- it('for a link that leads out of every write root, or to a device', async () => { +- symlinkSync(OUTSIDE, join(ROOT, 'leads-out')) +- expect(await heldBy(inRoot(['leads-out/file', 'blank']))).toEqual([ +- ...pairs('blank'), +- [join(ROOT, 'leads-out'), OUTSIDE], ++ it('for a deny written from the home or the working directory, and a name denied in every one', async () => { ++ fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) ++ process.chdir(ROOT) ++ const fromHome = `~/${relative(homedir(), join(ROOT, 'way', 'x'))}` ++ expect(await heldBy(['name', fromHome])).toEqual([ ++ ['.bashrc', 'conf/x'], ++ ...said(['name', 'way']), + ]) + }) + +- it('under a write root that is the root', async () => { +- expect( +- await heldBy([], { +- writeConfig: { allowOnly: ['/'], denyWithinAllow: inRoot(['name']) }, +- }), +- ).toEqual(pairs('name')) +- }) +- +- it('none for a path with no link on the way', async () => { +- expect(await heldBy(inRoot(['beside', 'conf/x', 'absent']))).toEqual([]) +- }) +- +- it('none that lies where the command cannot write', async () => { +- symlinkSync(join(ROOT, 'conf', 'x'), join(OUTSIDE, 'into-root')) +- expect(await heldBy([join(OUTSIDE, 'into-root')])).toEqual([]) +- }) +- +- it('none in the directories the sandbox mounts anew', async () => { ++ it('under a write root that is the root, but none in the directories the sandbox mounts anew', async () => { ++ const denyWithinAllow = ['/proc/self/cwd/file', ...inRoot(['name', 'out'])] + expect( +- await heldBy([], { +- writeConfig: { +- allowOnly: ['/'], +- denyWithinAllow: ['/proc/self/cwd/file', join(ROOT, 'out')], +- }, +- }), +- ).toEqual(pairs('out')) +- }) +- +- it('none that a read deny hides', async () => { +- const options = { readConfig: { denyOnly: [join(ROOT, 'holder')] } } +- const denied = inRoot(['holder/commands', 'name']) +- expect(await heldBy(denied, options)).toEqual(pairs('name')) +- if (bwrapCanNamespace()) { +- expect(stdoutOf(await wrapLinux(denied, options))).toBe('started\n') +- } +- }) +- +- it('none that is bound back by itself into a directory a read deny hides', async () => { +- mkdirSync(join(ROOT, 'holder', 'inner')) +- symlinkSync('inner', join(ROOT, 'holder', 'to-inner')) +- const options = { +- readConfig: { +- denyOnly: [join(ROOT, 'holder')], +- allowWithinDeny: inRoot(['holder/to-inner']), +- }, +- } +- const denied = inRoot(['holder/to-inner/file']) +- expect(await heldBy(denied, options)).toEqual([]) +- if (bwrapCanNamespace()) { +- expect(stdoutOf(await wrapLinux(denied, options))).toBe('started\n') ++ await heldBy([], { writeConfig: { allowOnly: ['/'], denyWithinAllow } }), ++ ).toEqual(said(['name', 'out'])) ++ }) ++ ++ it('none in a directory that a read deny hides, though the link is bound back by itself', async () => { ++ fs.mkdirSync(join(HOLDER, 'inner')) ++ fs.symlinkSync('inner', join(HOLDER, 'to-inner')) ++ const denied = inRoot(['holder/to-inner/file', 'holder/commands', 'name']) ++ const allowWithinDeny = [join(HOLDER, 'to-inner')] ++ const hiding = { readConfig: { denyOnly: [HOLDER], allowWithinDeny } } ++ expect(await heldBy(denied, hiding)).toEqual(said(['name'])) ++ if (namespaces) { ++ expect(run(await wrapLinux(denied, hiding)).stdout).toBe('started\n') + } + }) + +- it('the directories above a held link are pinned', async () => { +- const holder = join(ROOT, 'holder') +- expect(await wrapLinux(inRoot(['holder/commands']))).toContain( +- `--ro-bind ${holder} ${holder} `, +- ) +- }) +- +- it.if(modesCount)( +- 'a directory that cannot be looked in is bound read-only in place of the path', +- async () => { +- const holder = join(ROOT, 'holder') +- chmodSync(holder, 0) +- const { result: wrapped, warnings } = await withCapturedWarnings(() => +- wrapLinux(inRoot(['holder/commands', 'holder/other'])), +- ) +- // Beneath the write root's bind it is a pin, after it a deny. +- expect( +- wrapped.lastIndexOf(`--ro-bind ${holder} ${holder} `), +- ).toBeGreaterThan(wrapped.indexOf(`--bind ${ROOT} ${ROOT} `)) +- expect(wrapped).not.toContain(`${holder}/`) +- expect(warnings.join()).toContain(`denying ${holder} whole`) +- }, +- ) +- +- it.each<[string, (name: string) => Partial]>([ +- ['a read deny', name => ({ readConfig: { denyOnly: [name] } })], +- [ +- 'a match of a read-deny pattern', +- name => ({ readConfig: { denyOnly: [], matchedLinks: [name] } }), +- ], +- [ +- 'a masked file', +- name => ({ maskedFileBinds: [{ realPath: name, fakePath: '/fakes/0' }] }), +- ], +- ])('the links on the way to %s', async (_what, options) => { +- expect(await heldBy([], options(join(ROOT, 'first')))).toEqual( +- pairs('first', 'middle'), ++ // In place of the paths in it. ++ it.if(modesCount)('denies a directory it cannot look in whole', async () => { ++ fs.chmodSync(HOLDER, 0) ++ const { result: wrapped, warnings } = await withCapturedWarnings(() => ++ wrapLinux(inRoot(['holder/commands', 'holder/other'])), + ) ++ // Beneath the write root's bind it is a pin, after it a deny. ++ expect( ++ wrapped.lastIndexOf(`--ro-bind ${HOLDER} ${HOLDER} `), ++ ).toBeGreaterThan(wrapped.indexOf(`--bind ${ROOT} ${ROOT} `)) ++ expect(wrapped).not.toContain(`${HOLDER}/`) ++ expect(warnings.join()).toContain(`denying ${HOLDER} whole`) + }) + +- it('a link that leads to nothing yet, for a read deny on it', async () => { ++ it('the links on the way to a read deny, one that leads to nothing yet too, to a match of a read-deny pattern and to a masked file', async () => { ++ const [realPath, ...denyOnly] = inRoot(['way/secret', 'first', 'dangling']) + expect( +- await heldBy([], { readConfig: { denyOnly: inRoot(['dangling']) } }), +- ).toEqual(pairs('dangling')) ++ await heldBy([], { ++ readConfig: { denyOnly, matchedLinks: inRoot(['name']) }, ++ maskedFileBinds: [{ realPath: realPath!, fakePath: '/fakes/0' }], ++ }), ++ ).toEqual(said(['dangling', 'first', 'middle', 'name', 'way'])) + }) + + it('a read deny that is where it resolves to is not walked', async () => { +- const spy = spyOn(fs, 'lstatSync') +- try { +- await wrapLinux([], { readConfig: { denyOnly: inRoot(['conf/x']) } }) +- expect( +- spy.mock.calls.filter(([p]) => String(p).startsWith(ROOT)), +- ).toEqual([]) +- } finally { +- spy.mockRestore() +- } ++ const { calls } = spyOn(fs, 'lstatSync').mock ++ await wrapLinux([], { readConfig: { denyOnly: inRoot(['conf/x']) } }) ++ expect(calls.filter(([p]) => String(p).startsWith(ROOT))).toEqual([]) + }) + + it('a refusal for length says how much of it names links', async () => { +- const wrap = (denied: string[]): Promise => +- wrapLinux(denied, { command: 'a'.repeat(2 ** 21) }).then( +- () => 'wrapped', +- (error: Error) => error.message, +- ) ++ const refusal = (denied: string[]): Promise => ++ wrapLinux(denied, { command: 'a'.repeat(2 ** 21) }).catch(String) + const words = `--hold-link ${ROOT}/name conf/x` +- expect(await wrap(inRoot(['name']))).toContain( ++ expect(await refusal(inRoot(['name']))).toContain( + `At least ${words.length} of them are not the caller's`, + ) +- expect(await wrap([])).toMatch(/the limit here is \d+\)$/) ++ expect(await refusal([])).toMatch(/the limit here is \d+\)$/) + }) + }) + +-describe.if(isLinux)('where no link is held', () => { +- beforeEach(forgetLinuxUnheldLinks) +- +- /** A helper that notes each time it is run, then does as `script` says. */ +- const noting = (script: string): string => +- program('noting-helper', `echo run >> ${OUTSIDE}/runs\n${script}`) +- const runs = (): number => +- existsSync(join(OUTSIDE, 'runs')) +- ? readFileSync(join(OUTSIDE, 'runs'), 'utf8').split('\n').length - 1 +- : 0 +- /** As a helper built before the option does. */ +- const RUNS_ITS_WORDS = 'exec "$@"' +- /** `helper` for the one this library finds for itself. */ +- const found = (helper: string): { mockRestore(): void } => +- spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue(helper) +- const unheld = ( +- reason: LinuxUnheldLinkReason, +- ): ReturnType => +- inRoot(['first', 'middle']).map(path => ({ path, reason })) ++type Config = Parameters[0] + +- it.each<[LinuxUnheldLinkReason, string, () => Partial]>([ +- ['no_helper', 'no helper runs', () => ({ allowAllUnixSockets: true })], +- [ +- 'told_no', +- 'the caller says its helper does not hold links', +- () => ({ +- seccompConfig: { applyPath: noting('exit 0'), holdsLinks: false }, +- }), +- ], +- [ +- 'told_no', +- 'the caller turns holding off', +- () => ({ seccompConfig: { holdsLinks: false } }), +- ], +- [ +- 'not_told', +- "the helper is the caller's, which says nothing", +- () => ({ seccompConfig: { applyPath: noting('exit 0') } }), +- ], +- [ +- 'not_told', +- 'the same of one that is run under another name', +- () => ({ +- seccompConfig: { applyPath: noting('exit 0'), argv0: 'apply-seccomp' }, +- }), +- ], +- ])( +- '%s, %s: they are named, nothing is run to find out, and the command starts', +- async (reason, _what, rest) => { +- const { result: wrapped, warnings } = await withCapturedWarnings(() => +- wrapLinux(inRoot(['first']), rest()), +- ) +- +- expect(wrapped).not.toContain('--hold-link') +- expect(getLinuxUnheldLinks()).toEqual(unheld(reason)) +- for (const { path } of unheld(reason)) { +- expect( +- warnings.some(l => l.includes(`${path},`) && l.includes(reason)), +- ).toBe(true) +- } +- expect(runs()).toBe(0) +- if (bwrapCanNamespace() && !wrapped.includes('noting-helper')) { +- expect(stdoutOf(wrapped)).toBe('started\n') +- } +- }, +- ) ++/** ROOT for the one write root, and `first` denied in it. */ ++const config = (denyRead: string[], rest: Partial = {}): Config => ({ ++ network: { allowedDomains: [], deniedDomains: [] }, ++ filesystem: { denyRead, allowWrite: [ROOT], denyWrite: inRoot(['first']) }, ++ ...rest, ++}) + +- it("the caller's word that its helper holds links is taken, and nothing is run", async () => { +- const wrapped = await wrapLinux(inRoot(['first']), { +- seccompConfig: { +- applyPath: '/proc/self/fd/3', +- argv0: 'apply-seccomp', +- holdsLinks: true, +- }, +- }) +- expect(wrapped).toContain( +- `ARGV0=apply-seccomp /proc/self/fd/3 --hold-link ${ROOT}/first middle `, ++describe.if(isLinux)('where no link is held', () => { ++ const [NEWER, OLDER] = [join(OUTSIDE, 'newer'), join(OUTSIDE, 'older')] ++ const listed = SandboxManager.getLinuxUnheldLinks ++ type Reason = LinuxUnheldLink['reason'] ++ const unheld = (reason: Reason, ...names: string[]): LinuxUnheldLink[] => ++ inRoot(names).map(path => ({ path, reason })) ++ ++ // The reason if any, `seccomp`, and the helper the library finds for itself. ++ // The links are named, what holds one is not pinned for it either, and no ++ // helper is run to find out. ++ it.each<[Reason | '', Options['seccompConfig'], string]>([ ++ ['no_helper', {}, ''], ++ ['told_no', { holdsLinks: false }, ''], ++ ['told_no', { applyPath: NEWER, holdsLinks: false }, ''], ++ ['not_told', { applyPath: NEWER }, ''], ++ // What the library finds where nothing is at the path says nothing of it. ++ ['not_told', { applyPath: '/in/the/sandbox', argv0: 'apply-seccomp' }, ''], ++ ['', { applyPath: OLDER, holdsLinks: true }, ''], ++ ['', {}, NEWER], ++ ['older_helper', {}, OLDER], ++ ['no_answer', {}, OUTSIDE], ++ ])('"%s", told %j and finding "%s"', async (reason, seccompConfig, own) => { ++ // One names the option that says so, one runs its words as older builds do. ++ program('newer', `echo run > ${OUTSIDE}/run # --holds-links`) ++ program('older', `echo run > ${OUTSIDE}/run; exec "$@"`) ++ if (own) spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue(own) ++ const allowAllUnixSockets = reason === 'no_helper' ++ const { result: wrapped, warnings } = await withCapturedWarnings(() => ++ wrapLinux(inRoot(['far']), { seccompConfig, allowAllUnixSockets }), + ) +- expect(getLinuxUnheldLinks()).toEqual([]) +- }) +- +- it.each<[string, string, number, LinuxUnheldLinkReason | undefined]>([ +- ['names the option that says so', '# --holds-links', 0o755, undefined], +- ['does not', RUNS_ITS_WORDS, 0o755, 'older_helper'], +- ...(modesCount +- ? [ +- ['cannot be read', '', 0, 'no_answer'] as [ +- string, +- string, +- 0, +- 'no_answer', +- ], +- ] +- : []), +- ])( +- 'a helper the library found for itself is read, never run: one that %s', +- async (_what, script, mode, reason) => { +- const helper = noting(script) +- chmodSync(helper, mode) +- const spy = found(helper) +- try { +- const wrapped = await wrapLinux(inRoot(['first'])) +- expect(wrapped.includes('--hold-link')).toBe(reason === undefined) +- } finally { +- spy.mockRestore() +- } +- expect(getLinuxUnheldLinks()).toEqual(reason ? unheld(reason) : []) +- expect(runs()).toBe(0) +- }, +- ) + +- it('what holds a link is not pinned for it either', async () => { +- const pin = `--ro-bind ${ROOT}/holder ${ROOT}/holder ` +- const denied = inRoot(['holder/commands']) +- expect(await wrapLinux(denied)).toContain(pin) +- expect( +- await wrapLinux(denied, { seccompConfig: { holdsLinks: false } }), +- ).not.toContain(pin) ++ for (const words of ['--hold-link', `--ro-bind ${HOLDER} ${HOLDER} `]) { ++ expect(wrapped.includes(words)).toBe(reason === '') ++ } ++ const links = reason ? unheld(reason, 'far', 'holder/blank') : [] ++ expect(listed()).toEqual(links) ++ for (const { path } of links) { ++ expect( ++ warnings.some(l => l.includes(`${path},`) && l.includes(reason)), ++ ).toBe(true) ++ } ++ expect(fs.existsSync(join(OUTSIDE, 'run'))).toBe(false) ++ if (namespaces && !seccompConfig?.applyPath && !own) { ++ expect(run(wrapped).stdout).toBe('started\n') ++ } + }) + +- it('a link is listed by the latest wrap that met it', async () => { ++ it.each<[string, () => void | Promise]>([ ++ ['reset()', () => SandboxManager.reset()], ++ ['updateConfig()', () => SandboxManager.updateConfig(config([]))], ++ ])('listed by the latest wrap to meet it, until %s', async (_what, act) => { + await wrapLinux(inRoot(['first']), { allowAllUnixSockets: true }) ++ await wrapLinux(inRoot(['first']), { seccompConfig: { holdsLinks: false } }) + expect(await wrapLinux(inRoot(['middle']))).toContain('--hold-link') +- expect(getLinuxUnheldLinks()).toEqual(unheld('no_helper').slice(0, 1)) ++ expect(listed()).toEqual(unheld('told_no', 'first')) ++ await act() ++ expect(listed()).toEqual([]) + }) + + it('a wrap that throws names none', async () => { +- const refusal: unknown = await wrapLinux(inRoot(['first']), { +- allowAllUnixSockets: true, +- binShell: 'no-such-shell', +- }).catch((error: unknown) => error) +- expect(String(refusal)).toContain('no-such-shell') +- expect(getLinuxUnheldLinks()).toEqual([]) ++ const throwing = { allowAllUnixSockets: true, binShell: 'no-such-shell' } ++ const refusal = await wrapLinux(inRoot(['first']), throwing).catch(String) ++ expect(refusal).toContain("'no-such-shell' not found") ++ expect(listed()).toEqual([]) + }) + +- describe('as the manager lists them', () => { +- const config = (allowAllUnixSockets: boolean): SandboxRuntimeConfig => ({ +- network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets }, +- filesystem: { +- denyRead: [], +- allowWrite: [ROOT], +- denyWrite: inRoot(['first']), +- }, +- }) +- afterEach(() => SandboxManager.reset()) +- +- it('takes the word on the helper from the configuration', async () => { +- const { seccomp: parsed } = SandboxRuntimeConfigSchema.parse({ +- ...config(false), +- seccomp: { holdsLinks: false }, +- }) +- expect(parsed).toEqual({ holdsLinks: false }) +- await SandboxManager.initialize({ ...config(false), seccomp: parsed }) +- await SandboxManager.wrapWithSandbox('true') +- expect(SandboxManager.getLinuxUnheldLinks()).toEqual(unheld('told_no')) +- }) +- +- it('getFsReadConfig() names the links its patterns match, as a wrap does', async () => { +- const { filesystem, ...rest } = config(false) +- await SandboxManager.initialize({ +- ...rest, +- filesystem: { ...filesystem, denyRead: [join(ROOT, 'nam*')] }, +- }) +- expect(SandboxManager.getFsReadConfig().matchedLinks).toEqual( +- inRoot(['name']), +- ) +- }) +- +- it('lists them', async () => { +- await SandboxManager.initialize(config(true)) +- await SandboxManager.wrapWithSandbox('true') +- expect(SandboxManager.getLinuxUnheldLinks()).toEqual(unheld('no_helper')) +- }) +- +- it.each<[string, () => void | Promise]>([ +- ['reset()', () => SandboxManager.reset()], +- ['updateConfig()', () => SandboxManager.updateConfig(config(true))], +- ])('%s empties the list', async (_what, act) => { +- await SandboxManager.initialize(config(true)) +- await SandboxManager.wrapWithSandbox('true') +- expect(SandboxManager.getLinuxUnheldLinks()).not.toEqual([]) +- await act() +- expect(SandboxManager.getLinuxUnheldLinks()).toEqual([]) +- }) ++ it('the manager takes the word on the helper from the configuration, and names the links its patterns match', async () => { ++ await SandboxManager.initialize( ++ SandboxRuntimeConfigSchema.parse( ++ config(inRoot(['nam*', 'besid*']), { seccomp: { holdsLinks: false } }), ++ ), ++ ) ++ expect(SandboxManager.getFsReadConfig().matchedLinks).toEqual( ++ inRoot(['name']), ++ ) ++ await SandboxManager.wrapWithSandbox('true') ++ expect(listed()).toEqual(unheld('told_no', 'first', 'middle', 'name')) + }) + }) + +-describe.if(isLinux && bwrapCanNamespace())('the seccomp helper', () => { ++describe.if(namespaces)('the seccomp helper', () => { + const helper = (): string => seccomp.getApplySeccompBinaryPath()! +- const run = ( +- runnable: string, +- words: string[], +- env: Record = {}, +- ): { status: number | null; said: string } => { +- const result = spawnSync(runnable, words, { +- encoding: 'utf8', +- timeout: 30000, +- cwd: ROOT, +- env: { ...process.env, ...env }, +- }) +- return { status: result.status, said: result.stdout + result.stderr } +- } ++ /** For a shell in ROOT. With no word on what the first of them says. */ ++ const HOLDS = ++ '--hold-link name "" --hold-link way real --hold-link dangling later' + /** Lifts the helper's namespaces filter, which refuses every mount call. */ +- const NAMESPACES_ALLOWED = { SRT_ALLOW_NESTED_USERNS: '1' } +- const holding = (...names: Array): string[] => +- names.flatMap(name => ['--hold-link', join(ROOT, name), LINKS[name]]) +- /** umount2() with UMOUNT_NOFOLLOW, which reaches a mount on a link. */ +- const UNMOUNT = [ +- 'python3', +- '-c', +- [ +- 'import ctypes, errno, sys', +- 'libc = ctypes.CDLL(None, use_errno=True)', +- 'r = libc.umount2(sys.argv[1].encode(), 8)', +- 'print("unmounted" if r == 0 else errno.errorcode[ctypes.get_errno()])', +- ].join('\n'), +- ] ++ const NAMESPACES_ALLOWED = 'SRT_ALLOW_NESTED_USERNS=1' ++ const ELSEWHERE = 'leads elsewhere than it did' + + it('says that it holds links', () => { +- expect(run(helper(), ['--holds-links'])).toEqual({ status: 0, said: '' }) ++ expect(run(`${helper()} --holds-links`).status).toBe(0) + }) + + it.each([ +- ['a file is at the name', 'beside', '', 'not a symbolic link'], +- ['nothing is at the name', 'absent', '', 'No such file or directory'], +- ['the link says more', 'name', 'conf', 'leads elsewhere than it did'], +- ['the link says less', 'name', 'conf/xy', 'leads elsewhere than it did'], +- [ +- 'the link says another thing', +- 'name', +- 'conf/y', +- 'leads elsewhere than it did', +- ], ++ ['a file is at the name', 'beside', '""', 'not a symbolic link'], ++ ['nothing is at the name', 'absent', '""', 'No such file or directory'], ++ ['the link says more', 'name', 'conf', ELSEWHERE], ++ ['the link says less', 'name', 'conf/xy', ELSEWHERE], ++ ['the link says another thing', 'name', 'conf/y', ELSEWHERE], + ])('runs no command where %s, and says why', (_what, name, target, why) => { + expect( +- run(helper(), ['--hold-link', join(ROOT, name), target, 'echo', 'ran']), +- ).toEqual({ ++ run(`${helper()} --hold-link ${name} ${target} echo ran 2>&1`, ROOT), ++ ).toMatchObject({ + status: 1, +- said: `apply-seccomp: --hold-link ${join(ROOT, name)}: ${why} (seccomp.holdsLinks: false in the sandbox configuration holds no link)\n`, ++ stdout: `apply-seccomp: --hold-link ${name}: ${why} (seccomp.holdsLinks: false in the sandbox configuration holds no link)\n`, + }) + }) + +- it('holds a link it is given no word on', () => { +- expect( +- run(helper(), ['--hold-link', join(ROOT, 'name'), '', 'rm', 'name']).said, +- ).toMatch(BUSY) +- }) +- +- // The filter answers first, whoever asks. Where it is lifted, this user lacks +- // the capability, and uid 0 meets the lock. ++ // It has every capability in its namespace. The filter refuses the unmount; ++ // where it is lifted, the lock does. + it.each([ +- ['this user', [], {}, 'EPERM'], +- ['uid 0, with every capability in its namespace', ['-Ur'], {}, 'EPERM'], +- ['this user, allowed namespaces', [], NAMESPACES_ALLOWED, 'EPERM'], +- ['uid 0, allowed namespaces', ['-Ur'], NAMESPACES_ALLOWED, 'EINVAL'], +- ])('a command of %s cannot unmount the hold', (_who, as, env, errno) => { +- const { said } = run( +- 'unshare', +- [...as, helper(), ...holding('name'), ...UNMOUNT, join(ROOT, 'name')], +- env, ++ ['', 'EPERM'], ++ [NAMESPACES_ALLOWED, 'EINVAL'], ++ ])('%s a command of uid 0 cannot unmount the hold', (env, errno) => { ++ // umount2() with UMOUNT_NOFOLLOW, which reaches a mount on a link. ++ const unmount = ++ 'import ctypes, errno; ctypes.CDLL(None, use_errno=True).umount2(b"name", 8); print(errno.errorcode[ctypes.get_errno()])' ++ const { stdout } = run( ++ `${env} unshare -Ur ${helper()} ${HOLDS} sh -c 'python3 -c "$0"; rm name 2>&1' '${unmount}'`, ++ ROOT, + ) +- expect(said.trim()).toBe(errno) ++ expect(stdout).toStartWith(`${errno}\n`) ++ expect(stdout).toMatch(BUSY) + }) + + it('a hold keeps the flags of the file system that holds the link', () => { +- const link = join(OUTSIDE, 'link') +- const { said } = run('unshare', [ +- ...['-Urm', 'sh', '-c'], +- `mount -t tmpfs -o noexec,noatime,nosymfollow none ${OUTSIDE} && ` + +- `echo read > ${OUTSIDE}/x && ln -s x ${link} && ` + +- `exec ${helper()} --hold-link ${link} x sh -c 'rm ${link}; cat ${link}'`, +- ]) +- expect(said).toMatch(BUSY) +- expect(said).toContain('Too many levels of symbolic links') +- expect(said).not.toContain('read') ++ const { stdout } = run( ++ `unshare -Urm sh -c 'mount -t tmpfs -o noexec,noatime,nosymfollow none ${OUTSIDE} && ` + ++ `cd ${OUTSIDE} && echo read > x && ln -s x link && ` + ++ `exec ${helper()} --hold-link link x sh -c "rm link; cat link"' 2>&1`, ++ ) ++ expect(stdout).toMatch(BUSY) ++ expect(stdout).toContain('Too many levels of symbolic links') ++ expect(stdout).not.toContain('read') + }) + + it.each(['--ro-bind', '--bind', '--dev-bind'])( + 'a bubblewrap the command may start, with %s / /, starts, and the hold holds there', + bind => { +- const { said } = run( +- helper(), +- [ +- ...holding('name', 'way', 'dangling'), +- ...['bwrap', bind, '/', '/', '--bind', ROOT, ROOT, '--dev', '/dev'], +- ...['sh', '-c', 'echo started; rm name way dangling'], +- ], +- NAMESPACES_ALLOWED, ++ const { stdout } = run( ++ `${NAMESPACES_ALLOWED} ${helper()} ${HOLDS} bwrap ${bind} / / --bind ${ROOT} ${ROOT} --dev /dev sh -c 'echo started; rm name way dangling' 2>&1`, ++ ROOT, + ) +- expect(said).toContain('started') +- expect(said.match(new RegExp(BUSY, 'gi'))).toHaveLength(3) ++ expect(stdout).toContain('started') ++ expect(stdout.match(new RegExp(BUSY, 'gi'))).toHaveLength(3) + }, + ) + + it('a link that says another thing by the time the command runs: nothing runs', async () => { + const wrapped = await wrapLinux(inRoot(['name'])) +- rmSync(join(ROOT, 'name')) +- symlinkSync('conf/z', join(ROOT, 'name')) +- const result = spawnSync(wrapped, { shell: true, encoding: 'utf8' }) +- expect(result.stdout).toBe('') +- expect(result.stderr).toContain( +- `apply-seccomp: --hold-link ${ROOT}/name: leads elsewhere than it did`, +- ) ++ fs.rmSync(join(ROOT, 'name')) ++ fs.symlinkSync('conf/z', join(ROOT, 'name')) ++ expect(run(wrapped)).toMatchObject({ ++ stdout: '', ++ stderr: expect.stringContaining(`--hold-link ${ROOT}/name: ${ELSEWHERE}`), ++ }) + }) + }) + +-describe.if(isLinux && bwrapCanNamespace())( +- 'A read deny on a name that is a link, over two commands', +- () => { +- const SECRET = 'secret-content' +- afterEach(() => SandboxManager.reset()) +- +- it.each< +- [string, string, (name: string) => Partial, string] +- >([ +- ['denyRead', 'file', name => ({ filesystem: lists([name]) }), ''], +- ['denyRead', 'dir', name => ({ filesystem: lists([name]) }), '/file'], +- [ +- 'a denyRead pattern', +- 'file', +- name => ({ filesystem: lists([`${name}*`]) }), +- '', +- ], +- [ +- 'a credential deny', +- 'file', +- path => ({ credentials: { files: [{ path, mode: 'deny' }] } }), +- '', +- ], +- [ +- 'a credential mask', +- 'file', +- path => ({ credentials: { files: [{ path, mode: 'mask' }] } }), +- '', +- ], +- ])( +- '%s on a link to a %s: the name stays, and what it leads to stays hidden', +- async (_kind, leadsTo, listing, inside) => { +- mkdirSync(join(OUTSIDE, 'dir')) +- writeFileSync(join(OUTSIDE, 'file'), SECRET) +- writeFileSync(join(OUTSIDE, 'dir', 'file'), SECRET) +- const name = join(ROOT, 'linked') +- symlinkSync(join(OUTSIDE, leadsTo), name) +- await SandboxManager.initialize({ +- network: { allowedDomains: [], deniedDomains: [] }, +- filesystem: lists([]), +- ...listing(name), +- }) +- const saidBy = async (command: string): Promise => { +- const said = stdoutOf( +- await SandboxManager.wrapWithSandbox(`echo ran; ${command}`), +- ) +- SandboxManager.cleanupAfterCommand() +- expect(said).toStartWith('ran\n') +- return said +- } +- +- await saidBy(`rm ${name}; mv ${name} ${name}-aside; echo x > ${name}`) +- expect(readlinkSync(name)).toBe(join(OUTSIDE, leadsTo)) +- expect( +- await saidBy(`cat ${join(OUTSIDE, leadsTo)}${inside}`), +- ).not.toContain(SECRET) +- }, +- ) +- +- function lists(denyRead: string[]): SandboxRuntimeConfig['filesystem'] { +- return { denyRead, allowWrite: [ROOT], denyWrite: [] } ++// The name stays, and what it leads to stays hidden. ++describe.if(namespaces)('a read deny on a link, over two commands', () => { ++ it.each<[string, string, string[], Array<'deny' | 'mask'>]>([ ++ ['denyRead', 'file', [''], []], ++ ['denyRead', 'dir', [''], []], ++ ['a denyRead pattern', 'file', ['*'], []], ++ ['a credential deny', 'file', [], ['deny']], ++ ['a credential mask', 'file', [], ['mask']], ++ ])('%s on a link to a %s', async (_kind, leadsTo, endings, modes) => { ++ const [name, target] = [join(ROOT, 'linked'), join(OUTSIDE, leadsTo)] ++ fs.mkdirSync(join(OUTSIDE, 'dir')) ++ fs.writeFileSync(join(OUTSIDE, 'file'), 'secret') ++ fs.writeFileSync(join(OUTSIDE, 'dir', 'file'), 'secret') ++ fs.symlinkSync(target, name) ++ const files = modes.map(mode => ({ path: name, mode })) ++ const denied = endings.map(ending => name + ending) ++ await SandboxManager.initialize(config(denied, { credentials: { files } })) ++ const saidBy = async (command: string): Promise => { ++ const { stdout } = run( ++ await SandboxManager.wrapWithSandbox(`echo ran; ${command}`), ++ ) ++ SandboxManager.cleanupAfterCommand() ++ expect(stdout).toStartWith('ran\n') ++ return stdout + } +- }, +-) ++ ++ await saidBy(`rm ${name}; mv ${name} ${name}-aside; echo x > ${name}`) ++ expect(fs.readlinkSync(name)).toBe(target) ++ expect(await saidBy(`cat ${target} ${target}/file`)).not.toContain('secret') ++ }) ++}) diff --git a/wip/693tests/write-deny-through-links.test.ts.txt b/wip/693tests/write-deny-through-links.test.ts.txt new file mode 100644 index 000000000..02e1dd2e5 --- /dev/null +++ b/wip/693tests/write-deny-through-links.test.ts.txt @@ -0,0 +1,671 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + mock, + spyOn, +} from 'bun:test' +import * as fs from 'fs' +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { homedir, tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import type { LinuxUnheldLink } from '../../src/index.js' +import * as seccomp from '../../src/sandbox/generate-seccomp-filter.js' +import { wrapCommandWithSandboxLinux } from '../../src/sandbox/linux-sandbox-utils.js' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import { SandboxRuntimeConfigSchema } from '../../src/sandbox/sandbox-config.js' +import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' +import { followLinks } from '../../src/sandbox/sandbox-utils.js' +import { bwrapCanNamespace } from '../helpers/bwrap-namespace.js' +import { withCapturedWarnings } from '../helpers/captured-warnings.js' +import { isLinux, isMacOS, isWindows } from '../helpers/platform.js' + +/** + * A deny on a path with a symbolic link on the way to it, the last name + * included, covers what the links lead to and keeps the links where they are. + * Everything beside them stays as writable as it was. + * + * Every link here is made on the host before the command is wrapped. + */ + +const CWD = process.cwd() +// Where the temp dir really is: macOS names it through a link. +const BASE = join(fs.realpathSync(tmpdir()), `write-deny-links-${process.pid}`) +const ROOT = join(BASE, 'root') // the one write root +const OUTSIDE = join(BASE, 'outside') // beside it, under no write root +const HOLDER = join(ROOT, 'holder') +const inRoot = (names: string[]): string[] => names.map(n => join(ROOT, n)) + +/** Link name in ROOT -> what it says. A deny leads through every one. */ +const LINKS = { + name: 'conf/x', + 'holder/commands': '../shared/commands', + dangling: 'later', + way: 'real', + here: '.', + first: 'middle', + middle: 'conf/y', + up: 'in/to/../../real/secret', + past: 'lost/../gone/../name', + deep: 'void/deeper/../../real/secret', + far: 'holder/blank', + 'holder/blank': '/dev/null', + out: '/dev/stdout', +} +type Link = keyof typeof LINKS +const said = (names: Link[]): string[][] => names.map(n => [n, LINKS[n]]) +/** + * A name in ROOT, the links on the way to it and where they lead; then the + * directories the way goes up from, and the names that are not there and that + * it goes up from all the same. + */ +const WAYS: Array<[string, Link[], string, string[], string[]]> = [ + ['name', ['name'], 'conf/x', [], []], + ['holder/commands', ['holder/commands'], 'shared/commands', ['holder'], []], + ['dangling', ['dangling'], 'later', [], []], + ['way/secret', ['way'], 'real/secret', [], []], + ['way/absent', ['way'], 'real/absent', [], []], + // A link is named where it lies, whatever it was reached through. + ['here/name', ['here', 'name'], 'conf/x', [], []], + ['first', ['first', 'middle'], 'conf/y', [], []], + ['up', ['up'], 'real/secret', ['in/to', 'in'], []], + ['past', ['past', 'name'], 'conf/x', [], ['lost', 'gone']], + // Further on among names that are not there: it ends at the first. + ['deep', ['deep'], 'void', [], []], +] +const DENIED = inRoot([...WAYS.map(([name]) => name), 'far', 'out']) +const FILES = ['conf/x', 'conf/y', 'conf/z', 'real/secret', 'beside'] +const ABSENT = ['later', 'real/absent', 'shared/commands/new', 'lost', 'gone'] +/** EBUSY, as glibc words it ("Device or resource busy") or musl. */ +const BUSY = /resource busy/i +/** No mode keeps uid 0 from looking. */ +const modesCount = process.getuid?.() !== 0 +const namespaces = isLinux && bwrapCanNamespace() + +beforeEach(() => { + if (isWindows) return + for (const dir of ['conf', 'shared/commands', 'real', 'holder', 'in/to']) { + fs.mkdirSync(join(ROOT, dir), { recursive: true }) + } + fs.mkdirSync(OUTSIDE) + for (const file of FILES) fs.writeFileSync(join(ROOT, file), 'original') + for (const [link, says] of Object.entries(LINKS)) { + fs.symlinkSync(says, join(ROOT, link)) + } +}) + +afterEach(async () => { + if (isWindows) return + mock.restore() + process.chdir(CWD) + // Removes what bubblewrap made to mount on, and empties the list of links. + await SandboxManager.reset() + fs.chmodSync(HOLDER, 0o755) + fs.rmSync(BASE, { recursive: true, force: true }) +}) + +/** `command`, run by a shell: unless told otherwise under no write root, so + * that nothing of the working directory's is protected or made. */ +const run = (command: string, cwd = OUTSIDE): SpawnSyncReturns => + spawnSync(command, { shell: true, encoding: 'utf8', cwd, timeout: 30000 }) + +type Options = Parameters[0] & + Parameters[0] +type Rest = Partial + +/** `denyWithinAllow` denied in ROOT, the write root beside two devices. */ +function denying(denyWithinAllow: string[], rest?: Rest): Options { + const allowOnly = [ROOT, '/dev/null', '/dev/stdout'] + return { + command: 'echo started', + needsNetworkRestriction: false, + readConfig: { denyOnly: [] }, + writeConfig: { allowOnly, denyWithinAllow }, + ...rest, + } +} + +describe.if(!isWindows)('the links on the way to a path', () => { + /** What is said of `p`, a name in ROOT, with the names in ROOT again. */ + const followed = (p: string): object => { + const { links, left, ...rest } = followLinks(`${ROOT}/${p}`) + const told = JSON.stringify({ links: [...links], left: [...left], ...rest }) + return JSON.parse(told.split(`${ROOT}/`).join('')) as object + } + const NONE = { left: [], passed: [] } + + it.each<(typeof WAYS)[number]>([ + ...WAYS, + ['beside', [], 'beside', [], []], + ['absent/name', [], 'absent/name', [], []], + ['beside/name', [], 'beside/name', [], []], + ['way/absent/deeper', ['way'], 'real/absent/deeper', [], []], + // Folded in text, as everywhere a configured name is read. + ['./way/./secret/', ['way'], 'real/secret', [], []], + ['holder/commands/../name', [], 'holder/name', [], []], + ])('%s: %p, ending at %s', (p, links, end, left, passed) => { + expect(followed(p)).toEqual({ links: said(links), left, passed, end }) + }) + + it.each([ + ['longer than a name can be', 'a'.repeat(300)], + ['with a NUL byte', 'nul\0byte'], + ])('a name %s: nothing can be there, so it has looked', (_what, name) => { + expect(followed(`way/${name}`)).toEqual({ links: said(['way']), ...NONE }) + }) + + // Not every file system takes such a target. + it.if(isLinux)('has no end past a target that is no text', () => { + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'raw')) + expect(followed('raw/file')).toEqual({ links: [['raw', '']], ...NONE }) + }) + + it('follows 40 links, and has no end past them, where the kernel gives up too', () => { + for (let i = 1; i <= 41; i++) { + fs.symlinkSync(i > 1 ? `hop-${i - 1}` : 'beside', join(ROOT, `hop-${i}`)) + } + expect(followed('hop-40')).toMatchObject({ end: 'beside' }) + expect(Object.keys(followed('hop-41'))).toEqual(['links', 'left', 'passed']) + expect(() => fs.readFileSync(join(ROOT, 'hop-41'))).toThrow('ELOOP') + }) + + it.if(modesCount)('says which directory it could not look in', () => { + fs.chmodSync(HOLDER, 0) + fs.symlinkSync('holder/commands/../made', join(ROOT, 'via')) + expect(followed('via')).toMatchObject({ ...NONE, unsearched: 'holder' }) + }) + + const failing = (at?: fs.PathLike): never => { + throw Object.assign(new Error('EIO'), { code: 'EIO', path: at }) + } + + it('looks again where a look fails', () => { + spyOn(fs, 'readlinkSync').mockImplementationOnce(failing) + expect(followed('name')).toMatchObject({ end: 'conf/x' }) + }) + + it('where that fails too: the directory of what it looked at', () => { + spyOn(fs, 'readlinkSync').mockImplementation(failing) + expect(followed('way/secret')).toMatchObject({ unsearched: ROOT }) + }) + + it('and does not say where: the directory of the path as written', () => { + spyOn(fs, 'readlinkSync').mockImplementation(() => failing()) + expect(followed('way/secret')).toMatchObject({ unsearched: 'way' }) + }) + + it('in the root: no directory, which would stand in for everything', () => { + spyOn(fs, 'lstatSync').mockImplementation(failing) + expect(followed('name')).toEqual({ links: [], ...NONE }) + }) +}) + +describe.if(namespaces || isMacOS)('a deny with a link on the way', () => { + /** What `attempt` came to, run in ROOT with every one of `denied` denied. */ + async function outcomeOf(attempt: string, denied = DENIED): Promise { + const options = denying(denied, { + command: `cd ${ROOT} && if ( ${attempt} ); then echo DONE; else echo refused; fi`, + // Linux mounts a read deny where it resolves, under the /dev it then + // makes anew, and bubblewrap before 0.5.0 takes no device to mount on. + readConfig: { denyOnly: isMacOS ? inRoot(['far']) : [] }, + }) + const { stdout } = run( + isLinux + ? await wrapCommandWithSandboxLinux(options) + : wrapCommandWithSandboxMacOS(options), + ) + SandboxManager.cleanupAfterCommand() + return stdout.trim() + } + + it.each([ + ...Object.keys(LINKS).flatMap(link => [ + `rm ${link}`, + `mv ${link} aside`, + `ln -sfn beside ${link}`, + ]), + ...['name', 'dangling', 'middle'].map( + link => `echo new > made && mv -f made ${link}`, + ), + // A write or a create through the links, and by the path they lead to. + ...[...new Set(WAYS.flatMap(([name, , end]) => [name, end]))].map( + at => `echo changed >> ${at} || echo new > ${at}/new`, + ), + 'rm conf/x', + // The directories above a link and above what it leads to, those the way + // goes up from, and the names that are not there. + 'mv holder aside', + 'mv conf aside', + 'mv in/to aside', + 'mv in aside', + 'mkdir lost', + 'ln -s conf gone', + ])('refuses: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('refused') + + for (const [link, says] of Object.entries(LINKS)) { + expect(fs.readlinkSync(join(ROOT, link))).toBe(says) + } + for (const file of FILES) { + expect(fs.readFileSync(join(ROOT, file), 'utf8')).toBe('original') + } + expect(inRoot(ABSENT).filter(fs.existsSync)).toEqual([]) + }) + + it.each([ + 'echo changed >> beside && rm beside', + 'echo new > new && echo new > holder/new', + 'echo changed >> conf/z', + 'ln -s conf/z free && ln -sfn beside free', + 'test "$(readlink name)$(cat name way/secret)" = conf/xoriginaloriginal', + 'echo x > /dev/null && cat /dev/null', + ])('allows: %s', async attempt => { + expect(await outcomeOf(attempt)).toBe('DONE') + }) + + it.each(['rm .bashrc', 'echo changed >> conf/z', 'rm .claude/commands'])( + 'the same of the names denied in every working directory: %s', + async attempt => { + fs.mkdirSync(join(ROOT, '.claude')) + fs.symlinkSync('conf/z', join(ROOT, '.bashrc')) + fs.symlinkSync('../shared/commands', join(ROOT, '.claude', 'commands')) + process.chdir(ROOT) + expect(await outcomeOf(attempt, [])).toBe('refused') + }, + ) + + it.if(modesCount)('a directory it cannot look in is kept whole', async () => { + expect(await outcomeOf('chmod 000 holder')).toBe('DONE') + const undoing = 'chmod 755 holder; rm holder/commands || rm -r holder' + expect(await outcomeOf(undoing)).toBe('refused') + expect(fs.lstatSync(HOLDER).mode & 0o777).toBe(0) + fs.chmodSync(HOLDER, 0o755) + expect(fs.readdirSync(HOLDER).sort()).toEqual(['blank', 'commands']) + }) +}) + +// Profile text only, so it runs on every POSIX host. +describe.if(!isWindows)('Seatbelt profile', () => { + /** The filters of the rule that denies `ops`, one to a line. */ + function deniedBy(ops: string, write: string[], read: string[] = []): string { + const profile = wrapCommandWithSandboxMacOS( + denying(write, { readConfig: { denyOnly: read } }), + ) + const at = profile.indexOf(`(deny ${ops}\n`) + expect(at).toBeGreaterThan(-1) + return profile.slice(at, profile.indexOf('(with message', at)) + } + const KEPT = 'file-write-unlink file-write-create' + const subpath = (name: string): string => `(subpath "${join(ROOT, name)}")\n` + const literal = (name: string): string => `(literal "${join(ROOT, name)}")\n` + + it.each(WAYS)('%s keeps %p, denies %s', (name, links, end, left, passed) => { + const denies = deniedBy('file-write*', inRoot([name])) + for (const at of [name, end, ...passed]) { + expect(denies).toContain(subpath(at)) + } + const kept = deniedBy(KEPT, inRoot([name])) + for (const at of [...links, ...left]) expect(kept).toContain(literal(at)) + }) + + it('so is one on a name denied in every working directory', () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + expect(deniedBy('file-write*', [])).toContain(subpath('conf/x')) + }) + + it('a pattern deny starts from both spellings of its directory', () => { + const denies = deniedBy('file-write*', inRoot(['way/*.key'])) + const matches = (file: string): boolean => + [...denies.matchAll(/^ {2}\(regex (".*")\)$/gm)].some(([, text]) => + new RegExp(JSON.parse(text!) as string).test(join(ROOT, file)), + ) + expect(matches('way/a.key')).toBe(true) + expect(matches('real/a.key')).toBe(true) + expect(matches('conf/a.key')).toBe(false) + }) + + it('nothing among the devices is denied or kept; the links to them are kept, and what holds one', () => { + fs.symlinkSync('/dev', join(ROOT, 'devices')) + const denied = inRoot(['far', 'out', 'devices']) + const kept = deniedBy(KEPT, denied) + expect(deniedBy('file-write*', denied) + kept).not.toContain('"/dev') + for (const name of ['far', 'holder/blank', 'holder', 'out', 'devices']) { + expect(kept).toContain(literal(name)) + } + }) + + it('nor is a device hidden by a read deny on a link to it', () => { + const denies = deniedBy('file-read*', [], inRoot(['far', 'name'])) + expect(denies).toContain(subpath('conf/x')) + expect(denies).not.toContain('"/dev') + }) + + it.if(modesCount)('a directory it cannot look in is denied whole', () => { + fs.chmodSync(HOLDER, 0) + expect(deniedBy('file-write*', inRoot(['holder/commands']))).toContain( + subpath('holder'), + ) + }) +}) + +function wrapLinux(denied: string[], rest?: Rest): Promise { + return wrapCommandWithSandboxLinux(denying(denied, rest)) +} + +/** An executable file in OUTSIDE that holds `script`. */ +function program(name: string, script: string): string { + const file = join(OUTSIDE, name) + fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) + return file +} + +/** + * The words a wrap hands the seccomp helper before the command, as [link in + * ROOT, target] pairs. Read by the shells themselves, through every level of + * quoting, and with no namespace made: a stand-in for bubblewrap runs what + * follows `--`, three words, and one for the helper only prints its words. + */ +async function heldBy(denied: string[], rest?: Rest): Promise { + const bwrapPath = program('bwrap-stand-in', 'shift $(($# - 3)); exec "$@"') + const applyPath = program('helper-stand-in', `printf '%s\\0' "$@"`) + const seccompConfig = { applyPath, holdsLinks: true } + const wrapped = await wrapLinux(denied, { bwrapPath, seccompConfig, ...rest }) + const words = run(wrapped).stdout.split('\0') + const held: string[][] = [] + for (let i = 0; words[i] === '--hold-link'; i += 3) { + held.push([relative(ROOT, words[i + 1]!), words[i + 2]!]) + } + expect(words.slice(3 * held.length + 1)).toEqual(['-c', 'echo started', '']) + return held.sort() +} + +describe.if(isLinux)('what the seccomp helper is asked to hold', () => { + it('each link once, with what it says; none for a path with no link on the way, or that lies where the command cannot write', async () => { + fs.symlinkSync(join(ROOT, 'conf', 'x'), join(OUTSIDE, 'in')) + const none = [...inRoot(['beside', 'absent']), join(OUTSIDE, 'in')] + expect(await heldBy([...DENIED, ...none])).toEqual( + Object.entries(LINKS).sort(), + ) + }) + + it('whatever characters the name and the target hold, U+FFFD too, with no word on a target that is no text', async () => { + const odd = ["sp ace'quote", 'new\nline', '$(echo x)`y`', 'x�y'] + for (const name of odd) fs.symlinkSync(`to ${name}`, join(ROOT, name)) + fs.symlinkSync(Buffer.from([0x78, 0xff]), join(ROOT, 'bytes')) + expect(await heldBy(inRoot([...odd, 'bytes']))).toEqual( + [...odd.map(name => [name, `to ${name}`]), ['bytes', '']].sort(), + ) + }) + + it('for a deny written from the home or the working directory, and a name denied in every one', async () => { + fs.symlinkSync('conf/x', join(ROOT, '.bashrc')) + process.chdir(ROOT) + const fromHome = `~/${relative(homedir(), join(ROOT, 'way', 'x'))}` + expect(await heldBy(['name', fromHome])).toEqual([ + ['.bashrc', 'conf/x'], + ...said(['name', 'way']), + ]) + }) + + it('under a write root that is the root, but none in the directories the sandbox mounts anew', async () => { + const denyWithinAllow = ['/proc/self/cwd/file', ...inRoot(['name', 'out'])] + expect( + await heldBy([], { writeConfig: { allowOnly: ['/'], denyWithinAllow } }), + ).toEqual(said(['name', 'out'])) + }) + + it('none in a directory that a read deny hides, though the link is bound back by itself', async () => { + fs.mkdirSync(join(HOLDER, 'inner')) + fs.symlinkSync('inner', join(HOLDER, 'to-inner')) + const denied = inRoot(['holder/to-inner/file', 'holder/commands', 'name']) + const allowWithinDeny = [join(HOLDER, 'to-inner')] + const hiding = { readConfig: { denyOnly: [HOLDER], allowWithinDeny } } + expect(await heldBy(denied, hiding)).toEqual(said(['name'])) + if (namespaces) { + expect(run(await wrapLinux(denied, hiding)).stdout).toBe('started\n') + } + }) + + // In place of the paths in it. + it.if(modesCount)('denies a directory it cannot look in whole', async () => { + fs.chmodSync(HOLDER, 0) + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['holder/commands', 'holder/other'])), + ) + // Beneath the write root's bind it is a pin, after it a deny. + expect( + wrapped.lastIndexOf(`--ro-bind ${HOLDER} ${HOLDER} `), + ).toBeGreaterThan(wrapped.indexOf(`--bind ${ROOT} ${ROOT} `)) + expect(wrapped).not.toContain(`${HOLDER}/`) + expect(warnings.join()).toContain(`denying ${HOLDER} whole`) + }) + + it('the links on the way to a read deny, one that leads to nothing yet too, to a match of a read-deny pattern and to a masked file', async () => { + const [realPath, ...denyOnly] = inRoot(['way/secret', 'first', 'dangling']) + expect( + await heldBy([], { + readConfig: { denyOnly, matchedLinks: inRoot(['name']) }, + maskedFileBinds: [{ realPath: realPath!, fakePath: '/fakes/0' }], + }), + ).toEqual(said(['dangling', 'first', 'middle', 'name', 'way'])) + }) + + it('a read deny that is where it resolves to is not walked', async () => { + const { calls } = spyOn(fs, 'lstatSync').mock + await wrapLinux([], { readConfig: { denyOnly: inRoot(['conf/x']) } }) + expect(calls.filter(([p]) => String(p).startsWith(ROOT))).toEqual([]) + }) + + it('a refusal for length says how much of it names links', async () => { + const refusal = (denied: string[]): Promise => + wrapLinux(denied, { command: 'a'.repeat(2 ** 21) }).catch(String) + const words = `--hold-link ${ROOT}/name conf/x` + expect(await refusal(inRoot(['name']))).toContain( + `At least ${words.length} of them are not the caller's`, + ) + expect(await refusal([])).toMatch(/the limit here is \d+\)$/) + }) +}) + +type Config = Parameters[0] + +/** ROOT for the one write root, and `first` denied in it. */ +const config = (denyRead: string[], rest: Partial = {}): Config => ({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead, allowWrite: [ROOT], denyWrite: inRoot(['first']) }, + ...rest, +}) + +describe.if(isLinux)('where no link is held', () => { + const [NEWER, OLDER] = [join(OUTSIDE, 'newer'), join(OUTSIDE, 'older')] + const listed = SandboxManager.getLinuxUnheldLinks + type Reason = LinuxUnheldLink['reason'] + const unheld = (reason: Reason, ...names: string[]): LinuxUnheldLink[] => + inRoot(names).map(path => ({ path, reason })) + + // The reason if any, `seccomp`, and the helper the library finds for itself. + // The links are named, what holds one is not pinned for it either, and no + // helper is run to find out. + it.each<[Reason | '', Options['seccompConfig'], string]>([ + ['no_helper', {}, ''], + ['told_no', { holdsLinks: false }, ''], + ['told_no', { applyPath: NEWER, holdsLinks: false }, ''], + ['not_told', { applyPath: NEWER }, ''], + // What the library finds where nothing is at the path says nothing of it. + ['not_told', { applyPath: '/in/the/sandbox', argv0: 'apply-seccomp' }, ''], + ['', { applyPath: OLDER, holdsLinks: true }, ''], + ['', {}, NEWER], + ['older_helper', {}, OLDER], + ['no_answer', {}, OUTSIDE], + ])('"%s", told %j and finding "%s"', async (reason, seccompConfig, own) => { + // One names the option that says so, one runs its words as older builds do. + program('newer', `echo run > ${OUTSIDE}/run # --holds-links`) + program('older', `echo run > ${OUTSIDE}/run; exec "$@"`) + if (own) spyOn(seccomp, 'getApplySeccompBinaryPath').mockReturnValue(own) + const allowAllUnixSockets = reason === 'no_helper' + const { result: wrapped, warnings } = await withCapturedWarnings(() => + wrapLinux(inRoot(['far']), { seccompConfig, allowAllUnixSockets }), + ) + + for (const words of ['--hold-link', `--ro-bind ${HOLDER} ${HOLDER} `]) { + expect(wrapped.includes(words)).toBe(reason === '') + } + const links = reason ? unheld(reason, 'far', 'holder/blank') : [] + expect(listed()).toEqual(links) + for (const { path } of links) { + expect( + warnings.some(l => l.includes(`${path},`) && l.includes(reason)), + ).toBe(true) + } + expect(fs.existsSync(join(OUTSIDE, 'run'))).toBe(false) + if (namespaces && !seccompConfig?.applyPath && !own) { + expect(run(wrapped).stdout).toBe('started\n') + } + }) + + it.each<[string, () => void | Promise]>([ + ['reset()', () => SandboxManager.reset()], + ['updateConfig()', () => SandboxManager.updateConfig(config([]))], + ])('listed by the latest wrap to meet it, until %s', async (_what, act) => { + await wrapLinux(inRoot(['first']), { allowAllUnixSockets: true }) + await wrapLinux(inRoot(['first']), { seccompConfig: { holdsLinks: false } }) + expect(await wrapLinux(inRoot(['middle']))).toContain('--hold-link') + expect(listed()).toEqual(unheld('told_no', 'first')) + await act() + expect(listed()).toEqual([]) + }) + + it('a wrap that throws names none', async () => { + const throwing = { allowAllUnixSockets: true, binShell: 'no-such-shell' } + const refusal = await wrapLinux(inRoot(['first']), throwing).catch(String) + expect(refusal).toContain("'no-such-shell' not found") + expect(listed()).toEqual([]) + }) + + it('the manager takes the word on the helper from the configuration, and names the links its patterns match', async () => { + await SandboxManager.initialize( + SandboxRuntimeConfigSchema.parse( + config(inRoot(['nam*', 'besid*']), { seccomp: { holdsLinks: false } }), + ), + ) + expect(SandboxManager.getFsReadConfig().matchedLinks).toEqual( + inRoot(['name']), + ) + await SandboxManager.wrapWithSandbox('true') + expect(listed()).toEqual(unheld('told_no', 'first', 'middle', 'name')) + }) +}) + +describe.if(namespaces)('the seccomp helper', () => { + const helper = (): string => seccomp.getApplySeccompBinaryPath()! + /** For a shell in ROOT. With no word on what the first of them says. */ + const HOLDS = + '--hold-link name "" --hold-link way real --hold-link dangling later' + /** Lifts the helper's namespaces filter, which refuses every mount call. */ + const NAMESPACES_ALLOWED = 'SRT_ALLOW_NESTED_USERNS=1' + const ELSEWHERE = 'leads elsewhere than it did' + + it('says that it holds links', () => { + expect(run(`${helper()} --holds-links`).status).toBe(0) + }) + + it.each([ + ['a file is at the name', 'beside', '""', 'not a symbolic link'], + ['nothing is at the name', 'absent', '""', 'No such file or directory'], + ['the link says more', 'name', 'conf', ELSEWHERE], + ['the link says less', 'name', 'conf/xy', ELSEWHERE], + ['the link says another thing', 'name', 'conf/y', ELSEWHERE], + ])('runs no command where %s, and says why', (_what, name, target, why) => { + expect( + run(`${helper()} --hold-link ${name} ${target} echo ran 2>&1`, ROOT), + ).toMatchObject({ + status: 1, + stdout: `apply-seccomp: --hold-link ${name}: ${why} (seccomp.holdsLinks: false in the sandbox configuration holds no link)\n`, + }) + }) + + // It has every capability in its namespace. The filter refuses the unmount; + // where it is lifted, the lock does. + it.each([ + ['', 'EPERM'], + [NAMESPACES_ALLOWED, 'EINVAL'], + ])('%s a command of uid 0 cannot unmount the hold', (env, errno) => { + // umount2() with UMOUNT_NOFOLLOW, which reaches a mount on a link. + const unmount = + 'import ctypes, errno; ctypes.CDLL(None, use_errno=True).umount2(b"name", 8); print(errno.errorcode[ctypes.get_errno()])' + const { stdout } = run( + `${env} unshare -Ur ${helper()} ${HOLDS} sh -c 'python3 -c "$0"; rm name 2>&1' '${unmount}'`, + ROOT, + ) + expect(stdout).toStartWith(`${errno}\n`) + expect(stdout).toMatch(BUSY) + }) + + it('a hold keeps the flags of the file system that holds the link', () => { + const { stdout } = run( + `unshare -Urm sh -c 'mount -t tmpfs -o noexec,noatime,nosymfollow none ${OUTSIDE} && ` + + `cd ${OUTSIDE} && echo read > x && ln -s x link && ` + + `exec ${helper()} --hold-link link x sh -c "rm link; cat link"' 2>&1`, + ) + expect(stdout).toMatch(BUSY) + expect(stdout).toContain('Too many levels of symbolic links') + expect(stdout).not.toContain('read') + }) + + it.each(['--ro-bind', '--bind', '--dev-bind'])( + 'a bubblewrap the command may start, with %s / /, starts, and the hold holds there', + bind => { + const { stdout } = run( + `${NAMESPACES_ALLOWED} ${helper()} ${HOLDS} bwrap ${bind} / / --bind ${ROOT} ${ROOT} --dev /dev sh -c 'echo started; rm name way dangling' 2>&1`, + ROOT, + ) + expect(stdout).toContain('started') + expect(stdout.match(new RegExp(BUSY, 'gi'))).toHaveLength(3) + }, + ) + + it('a link that says another thing by the time the command runs: nothing runs', async () => { + const wrapped = await wrapLinux(inRoot(['name'])) + fs.rmSync(join(ROOT, 'name')) + fs.symlinkSync('conf/z', join(ROOT, 'name')) + expect(run(wrapped)).toMatchObject({ + stdout: '', + stderr: expect.stringContaining(`--hold-link ${ROOT}/name: ${ELSEWHERE}`), + }) + }) +}) + +// The name stays, and what it leads to stays hidden. +describe.if(namespaces)('a read deny on a link, over two commands', () => { + it.each<[string, string, string[], Array<'deny' | 'mask'>]>([ + ['denyRead', 'file', [''], []], + ['denyRead', 'dir', [''], []], + ['a denyRead pattern', 'file', ['*'], []], + ['a credential deny', 'file', [], ['deny']], + ['a credential mask', 'file', [], ['mask']], + ])('%s on a link to a %s', async (_kind, leadsTo, endings, modes) => { + const [name, target] = [join(ROOT, 'linked'), join(OUTSIDE, leadsTo)] + fs.mkdirSync(join(OUTSIDE, 'dir')) + fs.writeFileSync(join(OUTSIDE, 'file'), 'secret') + fs.writeFileSync(join(OUTSIDE, 'dir', 'file'), 'secret') + fs.symlinkSync(target, name) + const files = modes.map(mode => ({ path: name, mode })) + const denied = endings.map(ending => name + ending) + await SandboxManager.initialize(config(denied, { credentials: { files } })) + const saidBy = async (command: string): Promise => { + const { stdout } = run( + await SandboxManager.wrapWithSandbox(`echo ran; ${command}`), + ) + SandboxManager.cleanupAfterCommand() + expect(stdout).toStartWith('ran\n') + return stdout + } + + await saidBy(`rm ${name}; mv ${name} ${name}-aside; echo x > ${name}`) + expect(fs.readlinkSync(name)).toBe(target) + expect(await saidBy(`cat ${target} ${target}/file`)).not.toContain('secret') + }) +}) diff --git a/wip/reviews/573.md b/wip/reviews/573.md new file mode 100644 index 000000000..794fe753d --- /dev/null +++ b/wip/reviews/573.md @@ -0,0 +1,13 @@ +# #573 whole-diff review (second pass), /code-review xhigh, at b0f47a3 vs origin/main c40cf14, 2026-10-20 ~10:42-10:50Z. 12 findings. No JS-side bug in parseGlob/setToRegex; 500k-body fuzz vs naive matcher clean. Cannot run sandbox-exec: 1-4, 9, macOS half of 6 rest on README/PR statements. +1 SEC(unmeasured) glob-pattern.ts:228 `[^/…]` that macOS engine is documented to misread as `[^/]` in denies is also emitted for ALLOW rules and require-not carve-outs, where wider = opens access. README silent. +2 TEST macos-glob-sets.test.ts:59 sandbox-exec test compiles deny with globToRegex (no tail); production uses denyGlobRegex (`(/.*)?$`), the shape README says is misread. Emitted regex never run. Cases leave out x. and x0. +3 glob-pattern.ts:219 setToRegex never sorts/merges ranges: `[!0-9]` -> `[^/0-9]`, `[!.]` -> `[^/.]` adjacent to '/', the named trigger. Merge into `[^/-9]`, `[^.-/]` may remove the cause. `[!abc]` adjacency untested on macOS. +4 macos:488 lateReadDenyFilters/generateReadDenyUnlinkRules decide carve-outs with JS reading of a deny regex the engine reads wider: allowRead cannot re-open what is over-denied. +5 CHECKED macos:228 entrySamplePath replaces every […] * ? with 'x': negated deny set excluding x never covers a glob allow's sample -> no carve-out; fails closed, functional break. +6 CHECKED README:549 `[!abc]` 'any character outside the set' but no `u` flag: never matches an astral char (file😀.txt uncovered by file[!0-9].txt) -> narrower than written. +7 glob-pattern.ts:157 flip is silent: nothing logged when `[!x]` changes meaning (known limit in PR text). +8 SIMPLIFY CHECKED glob-pattern.ts:230 setToRegex re-parses members, `?? []` fallback gives valid-but-wrong regex for malformed tokens; carry `ranges` on the token. +9 glob-pattern.ts:205 comment 'Nothing else needs more': `[` member written bare, never run under sandbox-exec; `[[:alpha:]]`, `[![:space:]]`. +10 DOC README:548 unchanged `[abc]` bullet 'Whatever stands between the brackets is a member' contradicts new bullet; quirk paragraph never says macOS nor allow rules. +11 TEST macos-glob-sets.test.ts:29 whole suite gated !isWindows only for file setup; pure-string half never runs on Windows; afterAll rmSync(undefined). +12 TEST glob-expand.test.ts:2378 ~230 lines of set-writing tests in the wrong file; tables duplicated across 3 files. diff --git a/wip/reviews/589-592.md b/wip/reviews/589-592.md new file mode 100644 index 000000000..88a224c26 --- /dev/null +++ b/wip/reviews/589-592.md @@ -0,0 +1,25 @@ +# #589 + #592 whole-diff review, /code-review xhigh, at 54716d5 (592) / f88e744 (589) vs origin/main c40cf14, 2026-10-20 ~10:50-11:02Z. 24 findings, no crash on normal path. proxy-only.ts change exercised by hand on Bun 1.4.2: correct. +[589] 1 yml:402 test:node covers only which-node-test.mjs + control-fd; 4 other suites spawn `node` and never run on Node 24. +[592] 2 run-bundled.ts:81 runner silently drops seccomp assertion + multicall modes when helper or rg missing, exits 0. +[592] 3 yml:528 Alpine leg rebuilds apply-seccomp against musl: not the static-glibc helper npm ships. +[592] 4 CHECKED eslint.config.js:48 CI lints before build: test/smoke linted with dist absent -> type-aware rules see any. +[589] 5 CHECKED yml:387 13 suites as bare `bun test` args = substring filters: renamed suite drops silently. Prefix ./ +[592] 6 embedder.ts:143 'dependency check finds nothing missing' cannot fail; reads errors not warnings. +[592] 7 embedder.ts:87 check() process.exit(1) inside try: finally never runs, leaks tmp root. +[592] 8 embedder.ts:156 'network is closed' has no positive control. +[592] 9 embedder.ts:52 inherited ARGV0 / SRT_SMOKE_* override dispatch. +[592] 10 CHECKED run-bundled.ts:106 run.stdout null -> write(null) throws, hides run.error. +[592] 11 CHECKED run-bundled.ts:34 'dist/ is not built' guard unreachable via test:bundled (tsc fails first). +[592] 12 yml:525 Alpine `sh -euc` without pipefail on tar|tar. +[592] 13 yml:516 Alpine run is a 2nd step of same job: Ubuntu failure skips it. +[592] 14 yml:497,535 bun downloads without --retry. +[589] 15 yml:405 comment 'each release line from the engines floor up' but only 22, 24. +[589] 16 proxy-only.ts:317 only shipped-behaviour change came in MY merge commit f88e744; no test for rejected sink.write(); stale comment 'flush() throws rather than rejects'; commit 3133594's 'only comment lines' no longer true. +[589] 17 CONFIRMED parent-proxy.test.ts:16 untyped node:tls connect patched at 3 of ~33 sites; one typed helper would replace ~20 annotations. +[589] 18 yml:407 Node 22 step repeats work; test:node rebuilds dist each time. +[both] 19 yml:351 download/checksum/unpack block pasted 4x. +[592] 20 yml:524 images and distro packages float. +[592] 21 yml:33 macOS 26 leg puts OS version in matrix.arch. +[592] 22 run-bundled.ts:52 hand-rolled runner where bun:test would do. +[592] 23 embedder.ts:153 host paths unquoted in wrapped command. +[592] 24 yml:256 Typecheck comment 'the one step that checks types of test/' no longer true. diff --git a/wip/reviews/594.md b/wip/reviews/594.md new file mode 100644 index 000000000..916c2f95b --- /dev/null +++ b/wip/reviews/594.md @@ -0,0 +1,29 @@ +# #594 whole-diff review, /code-review xhigh, at 4f8bc03 vs origin/main c40cf14, 2026-10-20 ~10:20-10:32Z. 28 findings. 1-5, 9 reproduced by scripts; 4, 5 compared with main. +1 SEC REPRO manager:1636 no config installed -> hostHelperWritePaths() undefined = "nothing restricted": checkDependencies()/Async before initialize() run planted bwrap --version and npm root -g from PATH on host. npm's answer cached in applySeccompPathCache. +2 SEC REPRO linux:3977 the shell is still found by plain whichSync over whole PATH incl. writable entries; that shell is what bwrap execs and what launches apply-seccomp -> planted bash never execs apply-seccomp (no seccomp filter). +3 SEC REPRO generate-seccomp-filter.ts:227 sync getApplySeccompBinaryPath() calls getGlobalNpmPaths() without searchPath; the new param has no caller; sync check, getLinuxDependencyStatus and the WRAP run npm on unfiltered PATH (bundle/native build without vendored helper). +4 REGRESSION REPRO manager:1671 checkDependencies()/Async now throw (cwd removed + allowWrite ['.'] -> uv_cwd; half-marked entry -> TypeError) where main returns {errors,warnings}. +5 REGRESSION REPRO linux:2702 hostRipgrepConfig() requireHostHelper outside linuxGetMandatoryDenyPaths' try/catch: missing rg was soft (warning, cwd-level denies kept), now hard wrap failure. Undocumented. +6 manager:1556 hostSearchPath() ("POSIX only") is called by initialize() on every platform. Windows: every PATH entry refused -> npm root -g with PATH=/dev/null; writableForms realPathOf() on UNC allowWrite -> SMB request with user's creds (codebase never probes UNC). +7 SEC linux:1413 writable-helper warning judges only seccomp.applyPath option, not the resolved path; ~/.npm-global fallbacks unjudged: writable home -> planted helper picked silently. +8 linux:3993 in-sandbox relays use host-side absolute socat path chosen by write paths only; denyRead ~/.local hides it -> relays die silently, all proxied connections refused. +9 SEC REPRO host-helpers.ts:93 resolutionTrail: real dir a link target enters and leaves via '..' is popped, never judged: /safe/helper -> /hop/../../real/helper accepted; swap hop -> runs other file. +10 host-helpers.ts:63 writableForms counts write paths as Linux binds them, but is the macOS filter too: glob allowWrite on macOS counts for nothing. +11 DOC README:703 says npm/node looked up on filtered PATH "from initialize() and the async check"; not before a config exists, nor in the wrap. +12 which.ts:41 path.resolve on relative/empty PATH entries throws under Node when cwd removed (Bun does not). +13 which.ts:41 path.resolve folds '..' in a PATH entry by spelling; exec resolves via fs; hostSearchPath joins as the shell does -> the two disagree. +14 linux:3993 bwrap required "before anything else"; socat only after generateFilesystemArgs, rg partway: resolve all three at the top. +15 TEST own-files.test.ts:207,225 two regression tests weakened (said() turns exception into [message]) -> hide finding 4. [MY JOIN] +16 ALTITUDE host-helpers.ts:179 protection is an optional arg at each call site; omitted = unrestricted (fails open). Make "unrestricted" an explicit sentinel; resolve bwrap, socat, rg, shell, npm PATH once per write config into one object. +17 EFF manager:1768 hostSearchPath(...) evaluated eagerly on every checkDependenciesAsync()/initialize(); used only when helper/jar not local. Thunk. +18 EFF host-helpers.ts:178 writableForms recomputed each call (3x per wrap, ~6x per check). +19 SIMPLIFY host-helpers.ts:154 module-level `accepted` cache saves almost nothing, adds order-dependent state. +20 REUSE host-helpers.ts:85 resolutionTrail re-implements wayTo() of real-path.ts (2nd copy of 40-link limit, no textOf, no `/.` check). +21 REUSE host-helpers.ts:48 two definitions of "what the command may write" in one check (writableForms vs ownInstallWarning's pathSpellings). +22 REUSE linux:1330 isExecutableFile() duplicates weaker local isExecutable() (bwrapPath:'/usr/bin' passes). +23 SIMPLIFY manager:1684 result->error string mapping copied (rg here, onPath closure in checkLinuxDependencies, third in getLinuxDependencyStatus). +24 host-helpers.ts:296 writableNamedHelperWarning resolves `file` against cwd; argv0 mode applyPath may be a bare name -> false warning. +25 linux:1040 requireHostHelper always words refusal as "outside the paths the command may write" even when nothing skipped / nothing restricted. +26 linux:1887 dead fallback `shell || 'bash'`. +27 DOC README:1136 large-profile note still shows bare `bwrap`. +28 index.ts:54 SkippedHostHelper/HostHelperSearch not exported though README documents `.cause` shape. diff --git a/wip/reviews/607.md b/wip/reviews/607.md new file mode 100644 index 000000000..395007951 --- /dev/null +++ b/wip/reviews/607.md @@ -0,0 +1,21 @@ +# #607 whole-diff review, /code-review xhigh, at ba0a884 vs origin/main c40cf14, 2026-10-20 ~10:32-10:42Z. 19 findings. tsc/eslint/prettier clean. With stub bwrap+socat 31 pass 1 skip. Budget itself correct (spend outside every try; spent budget never returns a shortened list). +PR description already lists as known limits: 1, 6 (part), 7, 10. +1 MEASURED sandbox-utils:2046 entries served from shared `listings` cache are charged like disk reads: fits = 20M / number of ** patterns. 2.5M-entry monorepo x 9 globs = refused, though ~12 s of 60 s. Regression: ran before. +2 MEASURED sandbox-utils:1258 20M entries does not bound memory: listings keeps every Dirent (~100 B heap, 230-290 B RSS per entry) -> ~2.1 GB heap at 20M, OOM before either limit. +3 CONFIRMED manager:1964 only denyRead gets the budget; expandAllowReadGlob runs same walk unbounded. +4 CONFIRMED manager:2641 customConfig.denyReadGlobBudget ?? config's replaces the whole object: naming one field resets the other to default. Merge per field. No test. +5 CONFIRMED sandbox-utils:2016 typeless Dirents: withType() lstat per entry inside .map, no clock check/yield: time budget overshot by dir size x lstat latency. +6 CONFIRMED sandbox-utils:1284 limits checked only by zod schema (CLI loader). initialize/updateConfig/customConfig unchecked: {maxEntires:1} accepted; NaN turns limits off; 0/-1 refuse all; '1000' string concatenates. +7 manager:2634 refused wrap holds the single `walking` slot for whole budget, nothing remembered: over-budget config costs up to 60 s per command, serially; sandboxed command can cause it (ln -s /usr a ...). +8 linux:980 one code deny_glob_too_large for entries (lasting) and time (passing); discriminator on .cause (unknown); GlobWalkBudgetError not exported. +9 read-deny-glob.ts:128 post-walk work (candidate set, holdMatches, sort) in one generator step, no yield/spend, not abortable. +10 DOC README:568 deadline is wall time across event-loop turns: host's other work is charged; debug line reports inflated figure. +11 TEST CONFIRMED read-deny-walk-joins.test.ts:247 'gives up for the signal, not for the budget, when both have run out' never spends the budget (abort lands in allowRead walk); at listing 8 it rejects deny_glob_too_large: title's behaviour does not hold. +12 TEST CONFIRMED (mutation) manager:1988 anchored walk draws on budget / credential denies share budget: no test. +13 EFF MEASURED sandbox-utils:1823 performance.now() per entry: +24% Bun, +35% Node. +14 manager:1999 refusal always says 'denyRead pattern' though expander also serves credentials.files. +15 sandbox-utils:2166 directoriesListed vs entriesExamined use different bases. +16 SIMPLIFY sandbox-utils:1317 store timeoutMs instead of deadline, drop rounding. +17 SIMPLIFY read-deny-glob.ts:110 options literal written twice; Pick once. (#693's matchedLinks next) +18 TEST read-deny-glob-bound.test.ts:475,583 temp paths in RegExp unescaped. +19 TEST read-deny-walk-joins.test.ts:29 no platform gate (sibling has describe.if(!isWindows)). diff --git a/wip/reviews/693.md b/wip/reviews/693.md new file mode 100644 index 000000000..ea4a2c163 --- /dev/null +++ b/wip/reviews/693.md @@ -0,0 +1,24 @@ +# #693 whole-diff review, /code-review xhigh, at 438380f vs origin/main c40cf14, 2026-10-20 10:00-10:13Z +Reviewer ran the TS wrap code from scripts and the helper standalone (stub filters). No bwrap, no node_modules: PR's own suite not run by it. +C logic of the helper held up (unlink/rename/exchange/unmount refused). + +1 SEC linux-sandbox-utils.ts:1719 --holds-links probe runs the library-found helper on the HOST unsandboxed, without checking it lies outside write paths. (checkout / project-local node_modules + cwd writable -> command 1 overwrites vendor/seccomp//apply-seccomp, next wrap spawnSync runs it). no_answer never cached -> re-run each wrap. [joins with #594's idea] +2 CONFIRMED linux:3467 ancestors of each link are pinned even when nothing holds the link (not_told / holdsLinks:false): `mv build aside` EBUSY, link still replaceable; README says holding off = ordinary entry. Extra mounts count to BWRAP_MAX_ARGS. +3 CONFIRMED sandbox-utils.ts:176 any lstat/readlink failure outside NO_LINK_THERE -> parent returned as `unsearched` -> denied whole. denyWrite [/ + 'a'*300] (ENAMETOOLONG) makes whole write root read-only. EIO/ESTALE same. No retryingTransient. +4 CONFIRMED sandbox-utils.ts:187 every U+FFFD in a link target = "no text", stops the walk; real-path.ts textOf() compares bytes. a -> 'x�y', x�y/b -> ../real: b neither held nor listed; macOS end undefined -> no deny where links lead. +5 CONFIRMED SEC sandbox-utils.ts:161 dirs that a link target enters and leaves via `..` are not pinned: a -> d1/d2/../../real/secret; mv d1/d2 aside, plant link -> held link `a` resolves elsewhere. +6 CONFIRMED SEC macos-sandbox-utils.ts:1022 target through a missing name and back (`missing/../x`): macOS leaves `missing` creatable; Linux emits --ro-bind /dev/null /missing. +7 linux:3461 target given to --hold-link is from followLinks' look, deny mount from a separate realPathOf: X->Y->X flip race by a concurrent writer. +8 PLAUSIBLE apply-seccomp.c:770 holding on by default once binary answers; never asks whether kernel/LSM allows mounting on a symlink; refused mount is fatal -> every command exits 1; only way out holdsLinks:false. +9 linux:1718 probe is spawnSync 5 s x2 inside async wrap; no_answer uncached -> blocks event loop (proxies) up to 10 s per wrap. +10 sandbox-manager.ts:1237 Linux violation monitor's deny list lacks held links and stand-in dirs -> no violation recorded for new EBUSY/EROFS. +11 sandbox-manager.ts:2015 getFsReadConfig() expands denyRead globs without matchedLinks though it returns unlistableDenyDirs: two entry points, two policies. +12 linux:4112 unheldLinks is process-global: in-flight wrap can repopulate after updateConfig()/reset(); never pruned; not tied to a command. +13 linux:3973 hold words ride the command word (not --args file): ~1000 links -> command_too_long for every command; message does not name holdsLinks:false. +14 SIMPLIFY sandbox-utils.ts:150 followLinks re-implements wayTo() of src/utils/real-path.ts; 4 symlink walkers now (wayTo, followLinks, resolveSymlinkedDenyPath, findSymlinkInPath); already drifted (3, 4). +15 linux:1692 holdsLinksProbes cached by path for process life; reset()/updateConfig() do not clear: in-place up/downgrade. +16 TEST write-deny-through-links.test.ts:893 `expect(wrap).rejects.toThrow(...)` not awaited. +17 TEST :997,1024,1025,1042 hard-code glibc errno wording; docker probe still loads ctypes.CDLL('libc.so.6') (docker-weak-sandbox.test.ts:84). +18 linux:3482 mandatory-deny share in profile errors does not count pins added for links on the way to mandatory denies. +19 EFF linux:3444 canonicalLocationOf() also called for matchedLinks (links by construction): one wasted realpath each. +20 TEST :295 outcomeOf() console.logs every attempt's output (~40 cases) on every run. diff --git a/wip/tools/gccshim-gcc b/wip/tools/gccshim-gcc new file mode 100755 index 000000000..0bc31edc9 --- /dev/null +++ b/wip/tools/gccshim-gcc @@ -0,0 +1,10 @@ +#!/bin/bash +# For vendor/seccomp/build.ts on a machine without libseccomp-dev or a static libc in the default paths. +args=("$@") +if printf '%s\n' "${args[@]}" | grep -qx -- '-lseccomp'; then + out=() + for a in "${args[@]}"; do [ "$a" = "-static" ] || out+=("$a"); done + L=/nix/store/lxxk4dxshw0v4lwjzs3ywy3knx37gxgb-libseccomp-2.6.1-lib/lib + exec /usr/bin/gcc -I/opt/srt-stubs/build/seccomp-inc "${out[@]}" -L$L -Wl,-rpath,$L +fi +exec /usr/bin/gcc "${args[@]}" -L/nix/store/dr8sxxby6x9k0kg0s054cmcxqzpiy2yy-glibc-2.42-84-static/lib diff --git a/wip/tools/mutate594.py b/wip/tools/mutate594.py new file mode 100644 index 000000000..06c2977a6 --- /dev/null +++ b/wip/tools/mutate594.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""Mutation runner for PR 594's source. Works on a scratch copy of the repo. + +usage: mutate.py [--repo DIR] [--only ID,ID] [--shard I/N] [--list] +Each mutation is (id, what, file, [(old, new), ...], extra test files). +`old` must occur exactly once. After each run: git checkout -- src. +""" +import argparse, os, re, subprocess, sys + +HH = 'src/sandbox/host-helpers.ts' +WH = 'src/utils/which.ts' +LX = 'src/sandbox/linux-sandbox-utils.ts' +SM = 'src/sandbox/sandbox-manager.ts' +GS = 'src/sandbox/generate-seccomp-filter.ts' +JA = 'src/sandbox/java-proxy-agent.ts' +MC = 'src/sandbox/macos-sandbox-utils.ts' +RP = 'src/utils/real-path.ts' + +TESTS = [ + 'test/sandbox/host-helpers.test.ts', + 'test/utils/which.test.ts', + 'test/sandbox/check-dependencies.test.ts', + 'test/sandbox/linux-dependency-error.test.ts', + 'test/sandbox/own-files.test.ts', + 'test/sandbox/java-proxy-agent.test.ts', + 'test/sandbox/credential-deny.test.ts', +] +REAL_PATH = ['test/utils/real-path.test.ts'] +NODE = 'NODE' # also run test/utils/which-node-test.mjs + +M = [] + + +def m(id, what, file, pairs, extra=()): + if isinstance(pairs, tuple): + pairs = [pairs] + M.append((id, what, file, pairs, list(extra))) + + +# ---------------------------------------------------------------- host-helpers +m('HH01', "'anywhere' no longer gives the empty list", HH, + (" if (allowedWritePaths === 'anywhere') return []\n", '')) +m('HH02', 'policy unknown: the working directory is left out', HH, + (' ...(cwd === undefined ? [] : [cwd]),\n', '')) +m('HH03', 'policy unknown: the default write paths are left out', HH, + (' ...getDefaultWritePaths(),\n', '')) +m('HH04', 'policy unknown: process.cwd() asked outright (throws when gone)', HH, + ('const cwd = workingDirectory()', 'const cwd = process.cwd()')) +m('HH05', 'a pattern is not cut to the directory it starts from', HH, + ('containsGlobChars(normalized)\n ? globPatternBaseDir(normalized)\n : normalized', 'normalized')) +m('HH08', 'a spelling that is not absolute is kept', HH, + (' if (!path.isAbsolute(normalized)) continue\n', '')) +m('HH09', "a list naming '/' does not give the empty list", HH, + (" if (globBaseDirIsRoot(place)) return []\n", '')) +m('HH10', "only '/' itself gives the empty list, not a pattern from the root", HH, + ("if (globBaseDirIsRoot(place)) return []", "if (place === '/') return []")) +m('HH13', 'no short cut for the empty list', HH, + (' if (writable.length === 0) return undefined\n', '')) +m('HH14', 'the file as spelled is not judged', HH, + ('const places = [file]', 'const places: string[] = []')) +m('HH15', 'links followed are not judged', HH, + ('{ link: note, left: note }', '{ left: note }')) +m('HH16', 'directories gone up from are not judged', HH, + ('{ link: note, left: note }', '{ link: note }')) +m('HH17', 'the real file is not judged', HH, + (' places.push(real)\n', '')) +m('HH18', 'a way that ends at a missing name counts as followed', HH, + ('followed = notThere === undefined', 'followed = true')) +m('HH19', 'a way that throws counts as followed', HH, + ('let followed = false', 'let followed = true')) +m('HH20', 'compared by prefix of the text, not by segment', HH, + ('writable.find(w => isAtOrUnder(place, w))', 'writable.find(w => place.startsWith(w))')) +m('HH21', "the reason never says 'leads through'", HH, + ("${place === file ? '' : `leads through ${place}, `}inside", 'inside')) +m('HH22', 'the refusal carries no writePath', HH, + ('inside the allowed write path ${writePath}`,\n writePath,\n', 'inside the allowed write path ${writePath}`,\n')) +m('HH23', 'what cannot be followed is accepted', HH, + ("return followed ? undefined : { reason: 'it could not be followed to a file' }", 'return undefined')) +m('HH24', "other words for 'it could not be followed to a file'", HH, + ("{ reason: 'it could not be followed to a file' }", "{ reason: 'not followed' }")) +m('HH25', 'a path-qualified name is searched for', HH, + (' if (isPathQualified(name)) return { path: whichSync(name), skipped: [] }\n', '')) +m('HH26', 'a path-qualified name is judged like a found one', HH, + ('if (isPathQualified(name)) return { path: whichSync(name), skipped: [] }', + 'if (isPathQualified(name)) return { path: isOutside(name, writable) ? whichSync(name) : null, skipped: [] }')) +m('HH27', 'what is no executable file is a candidate', HH, + (' if (!isExecutableFile(file)) continue\n', '')) +m('HH28', 'a relative PATH entry is judged like any other', HH, + ('const refusal = path.isAbsolute(entry)\n ? refusalFor(file, writable)\n : { reason: `the PATH entry \'${entry}\' is relative` }', + 'const refusal = refusalFor(file, writable)')) +m('HH29', 'what was passed over is not recorded', HH, + (' skipped.push({ path: file, ...refusal })\n', '')) +m('HH30', 'none left: falls back to the bare name', HH, + (' return { path: null, skipped }\n', ' return { path: name, skipped }\n')) +m('HH31', 'hostSearchPath keeps relative entries', HH, + (' path.isAbsolute(entry) &&\n', '')) +m('HH32', 'hostSearchPath does not judge the entry as a directory', HH, + (' isOutside(entry, writable) &&\n', '')) +m('HH33', 'hostSearchPath does not judge the copies of the programs', HH, + (' return isOutside(file, writable)\n', ' return true\n')) +m('HH34', 'hostSearchPath: a dangling link is not judged (stat for lstat)', HH, + ('fs.lstatSync(file)', 'fs.statSync(file)')) +m('HH35', 'hostSearchPath: an entry without the program is left out', HH, + (' } catch {\n return true\n }', ' } catch {\n return false\n }')) +m('HH36', 'hostSearchPath folds `..` in text', HH, + ('const file = `${entry}/${program}`', 'const file = path.join(entry, program)')) +m('HH37', 'hostSearchPath may be empty', HH, + (" ||\n '/dev/null'\n", '\n')) +m('HH38', 'hostSearchPath throws on an unset PATH', HH, + ("(process.env.PATH ?? '')", 'process.env.PATH!')) +m('HH39', 'the refusal: nothing skipped is not told apart', HH, + (' if (skipped.length === 0) return nowhere\n', '')) +m('HH40', 'the refusal: a path gets the words of a name', HH, + ('nowhere = isPathQualified(helper)\n ? `${helper} is not an executable file`\n : `${helper} was not found on PATH`', + 'nowhere = `${helper} was not found on PATH`')) +m('HH41', 'the refusal: other opening words', HH, + ('`${helper} was not found on PATH outside the paths', '`${helper} is not on PATH outside the paths')) +m('HH42', 'the refusal: reasons left out', HH, + ('`${s.path} (${s.reason})`', '`${s.path}`')) +m('HH43', 'the refusal: does not name the option', HH, + ('or name it by its path with ${option}.', 'or name it by its path.')) +m('HH44', 'the warning is given for a bare name', HH, + ('file && isPathQualified(file) && refusalFor', 'file && refusalFor')) +m('HH45', 'the warning is given for what could not be followed', HH, + ('return refusal && refusal.writePath !== undefined', 'return refusal')) +m('HH46', 'the warning: a relative name is not resolved', HH, + ('refusalFor(path.resolve(file), writable)', 'refusalFor(file, writable)')) +m('HH47', "the warning: 'inside' not made 'is inside'", HH, + (".replace(/^inside/, 'is inside')", '')) + +# ----------------------------------------------------------------------- which +m('WH01', 'no name is path-qualified', WH, + ("return bin.includes('/') || bin.includes(path.sep)", 'return false'), [NODE]) +m('WH02', 'the execute bit is not asked for', WH, + (' fs.accessSync(file, fs.constants.X_OK)\n', ''), [NODE]) +m('WH03', 'a directory is an executable file', WH, + ('return fs.statSync(file).isFile()', 'return fs.statSync(file) !== undefined'), [NODE]) +m('WH04', 'the empty entry is the root', WH, + ("`${entry || '.'}${path.sep}${bin}`", '`${entry}${path.sep}${bin}`'), [NODE]) +m('WH05', 'an empty PATH is one empty entry', WH, + ('(process.env.PATH || undefined)?.split', "(process.env.PATH ?? '').split"), [NODE]) +m('WH06', 'candidates are path.resolve()d', WH, + ("file: `${entry || '.'}${path.sep}${bin}`", 'file: path.resolve(entry, bin)'), [NODE]) +m('WH07', 'candidates are path.join()ed', WH, + ("file: `${entry || '.'}${path.sep}${bin}`", "file: path.join(entry || '.', bin)"), [NODE]) +m('WH08', 'a path-qualified name is not checked', WH, + ('if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null', 'if (isPathQualified(bin)) return bin'), [NODE]) +m('WH09', 'Bun.which again (the PATH the process started with)', WH, + (' if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null\n', + " if (typeof globalThis.Bun !== 'undefined') return globalThis.Bun.which(bin)\n if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null\n"), [NODE]) +m('WH10', 'under Node a `which` program is run again', WH, + [("import * as path from 'node:path'\n", "import * as path from 'node:path'\nimport { spawnSync } from 'node:child_process'\n"), + (' if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null\n', + " if (typeof globalThis.Bun === 'undefined') {\n const r = spawnSync('which', [bin], { encoding: 'utf8' })\n return r.status === 0 && r.stdout ? r.stdout.trim() : null\n }\n if (isPathQualified(bin)) return isExecutableFile(bin) ? bin : null\n")], [NODE]) +m('WH11', 'the last candidate wins', WH, + ('pathCandidates(bin).find(', 'pathCandidates(bin).reverse().find('), [NODE]) + +# ----------------------------------------------------------------------- linux +m('LX01', "the wrap's bwrap: the bare name", LX, + ("bwrapPath ?? requireHostHelper('bwrap', 'bwrapPath', writable)", "bwrapPath ?? 'bwrap'")) +m('LX02', "the wrap's shell: the plain search", LX, + ("const shell = requireHostHelper(binShell || 'bash', 'binShell', writable)", + "const shell = requireHostHelper(binShell || 'bash', 'binShell', [])")) +m('LX03', "the wrap's shell: binShell ignored", LX, + ("requireHostHelper(binShell || 'bash', 'binShell', writable)", "requireHostHelper('bash', 'binShell', writable)")) +m('LX04', "the wrap's socat: the bare name", LX, + (" socatPath ?? requireHostHelper('socat', 'socatPath', writable),\n )", " socatPath ?? 'socat',\n )")) +m('LX05', "the wrap's write paths: allowOnly as handed in, literal entries not counted", LX, + ("writeConfig ? writeRootsOf(writeConfig) : 'anywhere',", "writeConfig ? (params.writeConfig?.allowOnly ?? []) : 'anywhere',")) +m('LX06', 'no write restriction: judged as a policy not known', LX, + ("writeConfig ? writeRootsOf(writeConfig) : 'anywhere',", 'writeConfig ? writeRootsOf(writeConfig) : undefined,')) +m('LX09', 'a ripgrep not to be had is hard: the wrap throws', LX, + (' signal.throwIfAborted()\n // INVARIANT: what ripgrep listed counts', + ' signal.throwIfAborted()\n if (error instanceof LinuxSandboxProfileError) throw error\n // INVARIANT: what ripgrep listed counts')) +m('LX10', 'the scan spawns ripgrep by the name given', LX, + (" command: requireHostHelper(\n ripgrepConfig.command,\n 'ripgrep.command',\n writable,\n ),\n", + ' command: ripgrepConfig.command,\n')) +m('LX11', "the scan judges by the default places, not the wrap's", LX, + (' abortSignal,\n writablePlaces(writeRootsOf(writeConfig)),\n', ' abortSignal,\n')) +m('LX12', 'a failed scan loses the cwd-level denies', LX, + (" logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, {\n level: 'warn',\n })\n", + " logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, {\n level: 'warn',\n })\n if (error instanceof LinuxSandboxProfileError) return []\n")) +m('LX13', 'a failed scan is not warned about', LX, + (" logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`, {\n level: 'warn',\n })\n", '')) +m('LX14', 'checkLinuxDependencies, places omitted: nothing passed over', LX, + (" const { seccompConfig, bwrapPath, socatPath } = opts ?? {}\n const writable = opts?.writable ?? writablePlaces(undefined)\n const errors", + " const { seccompConfig, bwrapPath, socatPath } = opts ?? {}\n const writable = opts?.writable ?? []\n const errors")) +m('LX15', 'getLinuxDependencyStatus, places omitted: nothing passed over', LX, + (" const writable = opts?.writable ?? writablePlaces(undefined)\n const onPath = (helper: string): boolean", " const writable = opts?.writable ?? []\n const onPath = (helper: string): boolean")) +m('LX16', 'getLinuxDependencyStatus: the plain search', LX, + (' findHostHelper(helper, writable).path !== null\n', ' findHostHelper(helper, []).path !== null\n')) +m('LX17', 'checkLinuxDependencies: the plain search', LX, + (' const search = findHostHelper(helper, writable)\n if (search.path === null) {\n errors.push(', + ' const search = findHostHelper(helper, [])\n if (search.path === null) {\n errors.push(')) +m('LX18', "checkLinuxDependencies: main's words for a helper that is nowhere are lost", LX, + ('describeUnavailableHostHelper(helper, option, search, notInstalled),', 'describeUnavailableHostHelper(helper, option, search),')) +m('LX19', 'checkLinuxDependencies: no warning about a named helper', LX, + (' if (warning !== undefined) warnings.push(warning)\n }\n\n const uid0Error', ' void warning\n }\n\n const uid0Error')) +m('LX20', 'checkLinuxDependencies: seccomp.applyPath is not warned about', LX, + (" ['seccomp.applyPath', seccompConfig?.applyPath],\n", '')) +m('LX21', 'checkLinuxDependencies: apply-seccomp looked up without the places', LX, + (" !seccompConfig?.argv0 &&\n getApplySeccompBinaryPath(seccompConfig?.applyPath, writable) === null", + " !seccompConfig?.argv0 &&\n getApplySeccompBinaryPath(seccompConfig?.applyPath) === null")) +m('LX22', 'getLinuxDependencyStatus: apply-seccomp looked up without the places', LX, + (' : getApplySeccompBinaryPath(seccompConfig?.applyPath, writable) !== null,', + ' : getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null,')) +m('LX23', 'the wrap: apply-seccomp looked up without the places', LX, + (' const binary = getApplySeccompBinaryPath(applyPath, writable)\n', ' const binary = getApplySeccompBinaryPath(applyPath)\n')) +m('LX24', 'the bridges: socat by the bare name', LX, + ("const socat = socatPath ?? requireHostHelper('socat', 'socatPath', writable)", "const socat = socatPath ?? 'socat'")) +m('LX25', "--disable-userns is asked of PATH's bwrap", LX, + [("import { realPathOf, wayTo } from '../utils/real-path.js'\n", "import { realPathOf, wayTo } from '../utils/real-path.js'\nimport { whichSync } from '../utils/which.js'\n"), + ('bwrapCanDisableUserns(bwrapBinary)', "bwrapCanDisableUserns(whichSync('bwrap') ?? '')")]) +m('LX26', "the uid-0 probe runs a bare 'true' inside", LX, + ("['--unshare-user', '--dev-bind', '/', '/', bwrap, '--version'],", "['--unshare-user', '--dev-bind', '/', '/', 'true'],")) +m('LX27', 'the typed refusal has another code', LX, + (" throw new LinuxSandboxProfileError(\n 'host_helper_unavailable',", " throw new LinuxSandboxProfileError(\n 'too_many_arguments',")) +m('LX28', 'the typed refusal has no cause', LX, + (' { helper, skipped: search.skipped },\n', '')) +m('LX29', 'the relays name socat by the bare name', LX, + ('const socat = quote([socatPath])', "const socat = 'socat'")) +m('LX30', 'the wrapped string names bwrap by the bare name', LX, + ('renderBwrapInvocation(bwrapBinary, bwrapArgs, mounts)', "renderBwrapInvocation(bwrapPath ?? 'bwrap', bwrapArgs, mounts)")) +m('LX31', 'the wrapped string names the shell by the bare name', LX, + ("bwrapArgs.push('--', shell, '-c')", "bwrapArgs.push('--', binShell || 'bash', '-c')")) +m('LX32', 'the bridges, places omitted: nothing passed over', LX, + (' socatPath?: string,\n writable: WritablePlaces = writablePlaces(undefined),\n): Promise', + ' socatPath?: string,\n writable: WritablePlaces = [],\n): Promise')) +m('LX33', 'the refusal of the wrap names another option for the shell', LX, + ("requireHostHelper(binShell || 'bash', 'binShell', writable)", "requireHostHelper(binShell || 'bash', 'shell', writable)")) + +# --------------------------------------------------------------------- manager +m('SM02', 'Windows is not asked by hostWritablePlaces', SM, + (" if (getPlatform() === 'windows') return writablePlaces('anywhere')\n", '')) +m('SM03', "no configuration counts as 'anywhere'", SM, + ('return writablePlaces(config && writeRootsOf(getFsWriteConfig()))', "return writablePlaces(config ? writeRootsOf(getFsWriteConfig()) : 'anywhere')")) +m('SM04', 'no configuration: the default write paths alone (no working directory)', SM, + ('return writablePlaces(config && writeRootsOf(getFsWriteConfig()))', 'return writablePlaces(writeRootsOf(getFsWriteConfig()))')) +m('SM05', 'a configuration that cannot be read makes the check throw', SM, + (' } catch {\n return writablePlaces(undefined)\n }', ' } catch (e) {\n throw e\n }')) +m('SM06', "a configuration that cannot be read counts as 'anywhere'", SM, + (' } catch {\n return writablePlaces(undefined)\n }', " } catch {\n return writablePlaces('anywhere')\n }")) +m('SM07', "the manager's ripgrep: the plain search", SM, + ('const search = findHostHelper(rgToCheck.command, writable)', 'const search = findHostHelper(rgToCheck.command, [])')) +m('SM08', "the manager's ripgrep: main's words are lost", SM, + (' search,\n `ripgrep (${rgToCheck.command}) not found`,\n', ' search,\n')) +m('SM09', "the manager's ripgrep: no warning about a named one", SM, + (" if (warning !== undefined) warnings.push(warning)\n\n const linuxDeps", ' void warning\n\n const linuxDeps')) +m('SM10', 'the manager does not hand its places to checkLinuxDependencies', SM, + (' socatPath: config?.socatPath,\n writable,\n', ' socatPath: config?.socatPath,\n')) +m('SM11', 'checkDependenciesAsync looks apply-seccomp up without the places', SM, + (' config?.seccomp?.applyPath,\n hostWritablePlaces(),\n', ' config?.seccomp?.applyPath,\n')) +m('SM12', 'initialize() looks the jar up without the places', SM, + (' config.javaAgentJarPath,\n hostWritablePlaces(),\n', ' config.javaAgentJarPath,\n')) +m('SM13', 'initialize() starts the bridges without the places', SM, + (' config.socatPath,\n hostWritablePlaces(),\n', ' config.socatPath,\n')) + +# --------------------------------------------------------------------- seccomp +m('GS01', 'win32: the environment is filtered too', GS, + (" return process.platform === 'win32'\n ? process.env\n : { ...process.env, PATH: hostSearchPath(writable, ['npm', 'node']) }", + " return { ...process.env, PATH: hostSearchPath(writable, ['npm', 'node']) }")) +m('GS02', 'sync: npm runs with the PATH as it is', GS, + (" stdio: ['pipe', 'pipe', 'ignore'],\n env: npmEnvironment(writable),\n", " stdio: ['pipe', 'pipe', 'ignore'],\n")) +m('GS03', 'async: npm runs with the PATH as it is', GS, + (' timeout: NPM_ROOT_TIMEOUT_MS,\n env: npmEnvironment(writable),\n', ' timeout: NPM_ROOT_TIMEOUT_MS,\n')) +m('GS04', "an entry's copy of node is not judged", GS, + ("hostSearchPath(writable, ['npm', 'node'])", "hostSearchPath(writable, ['npm'])")) +m('GS05', 'sync: the PATH is computed whether or not npm is run', GS, + [('): string[] {\n if (cachedGlobalNpmPaths) return cachedGlobalNpmPaths\n', '): string[] {\n const env = npmEnvironment(writable)\n if (cachedGlobalNpmPaths) return cachedGlobalNpmPaths\n'), + (" stdio: ['pipe', 'pipe', 'ignore'],\n env: npmEnvironment(writable),\n", " stdio: ['pipe', 'pipe', 'ignore'],\n env,\n")]) +m('GS06', 'async: the PATH is computed whether or not npm is run', GS, + [('): Promise {\n if (cachedGlobalNpmPaths) return Promise.resolve(cachedGlobalNpmPaths)\n', '): Promise {\n const env = npmEnvironment(writable)\n if (cachedGlobalNpmPaths) return Promise.resolve(cachedGlobalNpmPaths)\n'), + (' timeout: NPM_ROOT_TIMEOUT_MS,\n env: npmEnvironment(writable),\n', ' timeout: NPM_ROOT_TIMEOUT_MS,\n env,\n')]) +m('GS07', 'sync: remembered by applyPath alone', GS, + ("): string | null {\n const cacheKey = [seccompBinaryPath, ...writable].join('\\0')", "): string | null {\n const cacheKey = seccompBinaryPath ?? ''")) +m('GS08', 'async: remembered by applyPath alone', GS, + ("): Promise {\n const cacheKey = [seccompBinaryPath, ...writable].join('\\0')", "): Promise {\n const cacheKey = seccompBinaryPath ?? ''")) +m('GS09', 'a global install the command may write is taken', GS, + ('if (fs.existsSync(binaryPath) && isOutside(binaryPath, writable)) {', 'if (fs.existsSync(binaryPath)) {')) +m('GS10', 'sync: npm is asked without the places', GS, + ('findGlobalApplySeccompPath(getGlobalNpmPaths(writable), writable)', 'findGlobalApplySeccompPath(getGlobalNpmPaths(), writable)')) +m('GS11', 'async: npm is asked without the places', GS, + (' await getGlobalNpmPathsAsync(writable),\n', ' await getGlobalNpmPathsAsync(),\n')) +m('GS12', 'getGlobalNpmPaths, places omitted: the PATH as it is', GS, + ('export function getGlobalNpmPaths(\n writable: WritablePlaces = writablePlaces(undefined),', 'export function getGlobalNpmPaths(\n writable: WritablePlaces = [],')) +m('GS13', 'getGlobalNpmPathsAsync, places omitted: the PATH as it is', GS, + ('export function getGlobalNpmPathsAsync(\n writable: WritablePlaces = writablePlaces(undefined),', 'export function getGlobalNpmPathsAsync(\n writable: WritablePlaces = [],')) +m('GS14', 'getApplySeccompBinaryPath, places omitted: nothing passed over', GS, + (' seccompBinaryPath?: string,\n writable: WritablePlaces = writablePlaces(undefined),\n): string | null {', ' seccompBinaryPath?: string,\n writable: WritablePlaces = [],\n): string | null {')) +m('GS15', 'getApplySeccompBinaryPathAsync, places omitted: nothing passed over', GS, + (' seccompBinaryPath?: string,\n writable: WritablePlaces = writablePlaces(undefined),\n): Promise {', ' seccompBinaryPath?: string,\n writable: WritablePlaces = [],\n): Promise {')) + +# ------------------------------------------------------------------ java, macOS +m('JA01', 'the jar: npm is asked without the places', JA, + ('findGlobalJar(await getGlobalNpmPathsAsync(writable))', 'findGlobalJar(await getGlobalNpmPathsAsync())')) +m('JA02', 'the jar, places omitted: the PATH as it is', JA, + (' writable: WritablePlaces = writablePlaces(undefined),', ' writable: WritablePlaces = [],')) +m('MC01', "macOS: a bare 'env'", MC, (" '/usr/bin/env',\n", " 'env',\n")) +m('MC02', "macOS: a bare 'log'", MC, ("spawn('/usr/bin/log', [", "spawn('log', [")) +m('MC03', 'macOS: the shell is found like a host helper', MC, + [("import { whichSync } from '../utils/which.js'\n", "import { whichSync } from '../utils/which.js'\nimport { findHostHelper, writablePlaces } from './host-helpers.js'\n"), + ('const shell = whichSync(shellName)', "const shell = findHostHelper(shellName, writablePlaces(writeConfig?.allowOnly ?? 'anywhere')).path ?? (whichSync('') as null)")]) + +# ------------------------------------------------------------------- real-path +m('RP01', 'wayTo tells of no link', RP, (' watcher.link?.(next, target)\n', ''), REAL_PATH) +m('RP02', 'wayTo tells of no directory left', RP, (' watcher.left?.(real)\n', ''), REAL_PATH) +m('RP03', "wayTo: `..` at the root is told of ('' left)", RP, ("if (name === '..' && real !== '') {", "if (name === '..') {"), REAL_PATH) +m('RP04', 'EILSEQ carries no path', RP, (" code: 'EILSEQ',\n path: at,\n", " code: 'EILSEQ',\n"), REAL_PATH) +m('RP05', 'wayPastMissingNames always throws', RP, + (' const passed: string[] = []\n let onward = folded\n', " const passed: string[] = []\n let onward = folded\n if (onward) throw new Error('mutant')\n"), REAL_PATH) + + +def run(cmd, cwd, env, timeout=300): + try: + p = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout + p.stderr + except subprocess.TimeoutExpired as e: + return 124, 'TIMEOUT ' + str(e) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--repo', default='/tmp/claude-1000/tests594') + ap.add_argument('--only') + ap.add_argument('--shard') + ap.add_argument('--list', action='store_true') + ap.add_argument('--verbose', action='store_true') + ap.add_argument('--check', action='store_true') + a = ap.parse_args() + todo = M + if a.only: + want = a.only.split(',') + todo = [x for x in M if any(x[0].startswith(w) for w in want)] + if a.shard: + i, n = map(int, a.shard.split('/')) + todo = [x for k, x in enumerate(todo) if k % n == i] + if a.list: + for x in todo: + print(x[0], x[1]) + print(len(todo)) + return + if a.check: + for id, what, file, pairs, extra in todo: + text = open(os.path.join(a.repo, file)).read() + for old, _ in pairs: + if text.count(old) != 1: + print(id, text.count(old), repr(old[:70])) + print('checked', len(todo)) + return + env = dict(os.environ) + env['PATH'] = '/opt/srt-stubs:' + env['PATH'] + dirty = subprocess.run(['git', 'status', '--porcelain', '--', 'src'], cwd=a.repo, capture_output=True, text=True).stdout + if dirty.strip(): + sys.exit('src is dirty:\n' + dirty) + survivors = [] + for id, what, file, pairs, extra in todo: + p = os.path.join(a.repo, file) + text = open(p).read() + bad = [old for old, _ in pairs if text.count(old) != 1] + if bad: + print(f'{id} | CANNOT APPLY ({text.count(bad[0])} matches): {bad[0][:60]!r}', flush=True) + continue + for old, new in pairs: + text = text.replace(old, new) + open(p, 'w').write(text) + try: + tests = TESTS + [t for t in extra if t != NODE] + code, out = run(['bun', 'test', *tests], a.repo, env) + fails = {} + current = '?' + for line in out.splitlines(): + h = re.match(r'^(test/\S+):$', line) + if h: + current = os.path.basename(h.group(1)).replace('.test.ts', '') + if line.startswith('(fail)'): + fails[current] = fails.get(current, 0) + 1 + if code != 0 and not fails: + fails['(run failed)'] = 1 + node = '' + if NODE in extra: + ncode, nout = run(['node', 'test/utils/which-node-test.mjs'], a.repo, env) + built = 'Build failed' not in nout + node = f' | node: {"caught" if ncode != 0 and built else "BUILD FAILED" if not built else "passes"}' + if ncode != 0 and built: + fails['which-node'] = 1 + total = sum(fails.values()) + by = ', '.join(f'{k} {v}' for k, v in fails.items()) + print(f'{id} | {what} | {total if total else "SURVIVES"} | {by}{node}', flush=True) + if a.verbose: + print('\n'.join(l for l in out.splitlines() if l.startswith('(fail)'))) + if not total: + survivors.append(id) + finally: + subprocess.run(['git', 'checkout', '--', 'src'], cwd=a.repo, check=True) + print(f'{len(todo)} mutations, {len(survivors)} survive: {" ".join(survivors)}') + + +main() diff --git a/wip/tools/mutate607.py b/wip/tools/mutate607.py new file mode 100644 index 000000000..35fb18bc5 --- /dev/null +++ b/wip/tools/mutate607.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Applies each mutation to the scratch copy, runs the tests, reverts, prints what failed.""" +import subprocess, sys, os, re +ROOT = '/root/repos/anthropics/srt-wt/607' +U = 'src/sandbox/sandbox-utils.ts' +M = 'src/sandbox/sandbox-manager.ts' +R = 'src/sandbox/read-deny-glob.ts' +C = 'src/sandbox/sandbox-config.ts' +SPEND = " spend(dir, known === undefined ? 1 : 0)\n" +FAST_END = " !positions.matches(fresh, entry.name, '')\n ) {\n continue\n }\n" +BUDGET_WRAP = " const budget = readGlobBudget(\n config?.filesystem.denyReadGlobBudget,\n customConfig?.filesystem?.denyReadGlobBudget,\n )\n" +MUT = { + 'a spend(dir,1) always': [(U, SPEND, " spend(dir, 1)\n")], + 'b no spend in withType': [(U, " spend(real, 0)\n", "")], + 'c budget error not rethrown from the listing catch': [(U, " if (err instanceof GlobWalkBudgetError) throw err\n", "")], + 'd no spend after yield': [(U, " spend(dir, 0)\n", "")], + 'e1 no time check': [(U, " budget.entries > budget.maxEntries ||\n performance.now() - budget.startedAt >= budget.timeoutMs\n", " budget.entries > budget.maxEntries\n")], + 'e2 no entries check': [(U, " budget.entries > budget.maxEntries ||\n performance.now() - budget.startedAt >= budget.timeoutMs\n", " performance.now() - budget.startedAt >= budget.timeoutMs\n")], + 'f spend below the fast-path continue': [(U, SPEND, ""), (U, FAST_END, FAST_END + SPEND)], + 'g no budget beneath an anchor': [(M, "{ anchor, budget, listings }", "{ anchor, budget: anchor === undefined ? budget : undefined, listings }")], + 'h allowRead walks without the budget': [(M, "walkGlobPatternSteps(pattern, { anchor, budget })", "walkGlobPatternSteps(pattern, { anchor })")], + 'i1 no schema parse': [(M, " FilesystemConfigSchema.shape.denyReadGlobBudget.parse(given, {\n path: ['filesystem', 'denyReadGlobBudget'],\n }),\n", " given,\n")], + 'i2 last of limits only': [(M, "maxEntries: own?.maxEntries ?? session?.maxEntries,", "maxEntries: own?.maxEntries,")], + 'i3 last defined of limits only': [(M, "timeoutMs: own?.timeoutMs ?? session?.timeoutMs,", "timeoutMs: own ? own.timeoutMs : session?.timeoutMs,")], + 'j budget error unwrapped': [(M, " if (!(error instanceof GlobWalkBudgetError)) throw error\n", " throw error\n")], + 'k1 fresh budget per denyRead pattern': [(M, "{ anchor, budget, listings }", "{ anchor, budget: newGlobWalkBudget(budget), listings }")], + 'k2 fresh budget per allowRead pattern': [(M, "walkGlobPatternSteps(pattern, { anchor, budget })", "walkGlobPatternSteps(pattern, { anchor, budget: newGlobWalkBudget(budget) })")], + 'l read-deny-glob drops the budget': [(R, " ...opts,\n", " anchor: opts.anchor,\n listings: opts.listings,\n")], + 'm schema not strict': [(C, " timeoutMs: z.number().int().positive().optional(),\n })\n .strict()\n", " timeoutMs: z.number().int().positive().optional(),\n })\n")], + # Extras, for what the list above does not reach. + 'x1 budget kept across the restarts of a wrap': [(M, BUDGET_WRAP, " const budget = ((globalThis as any).__kept = restarts > 0 ? (globalThis as any).__kept : readGlobBudget(\n config?.filesystem.denyReadGlobBudget,\n customConfig?.filesystem?.denyReadGlobBudget,\n ))\n")], + 'x2 clock not read at an entry already read': [(U, SPEND, " if (known === undefined) spend(dir, 1)\n")], + 'x3 listings per pattern at the manager': [(M, "{ anchor, budget, listings }", "{ anchor, budget, listings: new Map() }")], + 'x4 clock read every 16th entry': [(U, " performance.now() - budget.startedAt >= budget.timeoutMs\n ) {", " ((read === 0 || budget.entries % 16 === 0) && performance.now() - budget.startedAt >= budget.timeoutMs)\n ) {")], + 'x5 getFsReadConfig ignores the configured budget': [(M, " const budget = readGlobBudget(config.filesystem.denyReadGlobBudget)\n", " const budget = readGlobBudget()\n")], + 'x6 wrap ignores the session budget': [(M, BUDGET_WRAP, " const budget = readGlobBudget(\n customConfig?.filesystem?.denyReadGlobBudget,\n )\n")], + 'x7 wrap ignores its own budget': [(M, BUDGET_WRAP, " const budget = readGlobBudget(\n config?.filesystem.denyReadGlobBudget,\n )\n")], + 'x8 maxEntries need be no integer': [(C, " maxEntries: z.number().int().positive().optional(),", " maxEntries: z.number().positive().optional(),")], + 'x9 timeoutMs need not be positive': [(C, " timeoutMs: z.number().int().positive().optional(),", " timeoutMs: z.number().int().optional(),")], + 'x12 at the limit is past it': [(U, " budget.entries > budget.maxEntries ||", " budget.entries >= budget.maxEntries ||")], + 'x13 allowRead and denyRead each get a budget (wrap)': [(M, " (pattern, anchor) => expandAllowReadGlob(budget, pattern, anchor),\n ),\n )\n if (expandedAllowRead === REPLACED)", " (pattern, anchor) => expandAllowReadGlob(newGlobWalkBudget(budget), pattern, anchor),\n ),\n )\n if (expandedAllowRead === REPLACED)")], + 'x14 defaults changed': [(U, "limits.maxEntries ?? 20_000_000", "limits.maxEntries ?? 2_000_000")], + 'x15 error names the spelled pattern wrongly': [(U, "throw new GlobWalkBudgetError(globPath, dir, budget)", "throw new GlobWalkBudgetError(normalizedPattern, dir, budget)")], +} +def sh(cmd): + return subprocess.run(cmd, shell=True, cwd=ROOT, capture_output=True, text=True) +def main(): + files = sys.argv[1:] or ['test/sandbox/read-deny-glob-bound.test.ts'] + env = 'PATH=/opt/srt-stubs:$PATH' + for name, edits in MUT.items(): + sh('git checkout -q -- src') + for path, old, new in edits: + p = os.path.join(ROOT, path) + s = open(p).read() + n = s.count(old) + if n != 1: + print(f'!! {name}: {n} matches in {path} for {old[:50]!r}'); break + open(p, 'w').write(s.replace(old, new)) + else: + r = sh(f"{env} bun test {' '.join(files)} 2>&1") + fails = [re.sub(r'\s*\[[\d.]+ms\]$', '', l[len('(fail) '):]) for l in r.stdout.splitlines() if l.startswith('(fail) ')] + print(f'## {name}: {len(fails)} failing' + ('' if fails else ' <<<<<< SURVIVES')) + for f in fails: print(' - ' + f[:170]) + sh('git checkout -q -- src') +main() diff --git a/wip/tools/setup-srt.sh b/wip/tools/setup-srt.sh new file mode 100755 index 000000000..4a7b885f5 --- /dev/null +++ b/wip/tools/setup-srt.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Sets a fresh Linux machine up for testing sandbox-runtime without packaged +# bubblewrap, socat or libseccomp headers. usage: setup-srt.sh ... +set -e +REPO=$1; WT=$2; shift 2 +STUBS=/opt/srt-stubs +sudo mkdir -p $STUBS && sudo chown "$(id -un)" $STUBS +mkdir -p $STUBS/build $STUBS/gccshim "$WT" +cd "$REPO" +[ "$(git rev-parse --is-shallow-repository)" = true ] && git fetch -q --unshallow origin +for pb in "$@" 751:claude/project-thread-u33gxn; do + n=${pb%%:*}; b=${pb#*:} + git fetch -q origin "$b" + [ -d "$WT/$n" ] || git worktree add -q --detach "$WT/$n" FETCH_HEAD +done +[ -d node_modules ] || HUSKY=0 npm install --no-save --no-audit --no-fund prettier@3.5.0 eslint-config-prettier@9.1.2 >/dev/null 2>&1 +T=$WT/751/wip/tools +gcc -O2 $T/socat.c -o $STUBS/socat +cp $T/gccshim-gcc $STUBS/gccshim/gcc; cp $T/stub-ok $STUBS/stub-ok; chmod +x $STUBS/gccshim/gcc $STUBS/stub-ok +# the shim names two nix store paths: bring them up to this machine's +SEC=$(ls -d /nix/store/*-libseccomp-*-lib/lib | head -1); GLC=$(ls -d /nix/store/*-glibc-*-static/lib | head -1) +sed -i -E "s#L=/nix/store/[^ ]*libseccomp[^ ]*#L=$SEC#; s#-L/nix/store/[^ ]*glibc[^ ]*static/lib#-L$GLC#" $STUBS/gccshim/gcc +cd $STUBS/build +[ -d bubblewrap ] || git clone -q https://github.com/containers/bubblewrap.git +[ -d libseccomp ] || git clone -q --depth 1 --branch v2.6.0 https://github.com/seccomp/libseccomp.git +mkdir -p seccomp-inc cfg inc/sys +sed -e 's/@VERSION_MAJOR@/2/; s/@VERSION_MINOR@/6/; s/@VERSION_MICRO@/0/' libseccomp/include/seccomp.h.in > seccomp-inc/seccomp.h +cp libseccomp/include/seccomp-syscalls.h seccomp-inc/ +cat > inc/sys/capability.h <<'H' +#pragma once +#include +typedef int cap_value_t; +int cap_from_name(const char *, cap_value_t *); +int capget(cap_user_header_t, cap_user_data_t); +int capset(cap_user_header_t, const cap_user_data_t); +H +echo '#define PACKAGE_STRING "bubblewrap 0.12.0"' > cfg/config.h +CAP=$(ls -d /nix/store/*-libcap-*-lib/lib | head -1) +( cd bubblewrap && git checkout -q v0.12.0 && gcc -O2 -D_GNU_SOURCE -I../cfg -I../inc -I. *.c -o $STUBS/bwrap -L$CAP -Wl,-rpath,$CAP -l:libcap.so.2 ) +cd "$REPO" +PATH=$STUBS/gccshim:$PATH bun vendor/seccomp/build.ts >/dev/null 2>&1 || echo "helper build failed" +for d in "$WT"/*/; do + [ -e "$d/node_modules" ] || ln -s "$REPO/node_modules" "$d/node_modules" + ( cd "$d" && PATH=$STUBS/gccshim:$PATH bun vendor/seccomp/build.ts >/dev/null 2>&1 ) || echo "helper build failed in $d" +done +$STUBS/bwrap --version; $STUBS/bwrap --unshare-user --dev-bind / / true && echo namespaces ok +ls -la vendor/seccomp/x64/ 2>/dev/null | tail -2 diff --git a/wip/tools/socat.c b/wip/tools/socat.c new file mode 100644 index 000000000..3363cb716 --- /dev/null +++ b/wip/tools/socat.c @@ -0,0 +1,128 @@ +// A small stand-in for socat: two addresses, relayed both ways. +// Knows UNIX-LISTEN, UNIX-CONNECT, TCP, TCP4, TCP6, TCP-LISTEN, TCP4-LISTEN, TCP6-LISTEN, STDIO and "-", +// and of the options only fork, backlog, ipv6only, reuseaddr, bind (others are ignored). +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct addr { char kind[32]; char a[4096]; char b[64]; int fork_, backlog, v6only; }; + +static void die(const char *what) { fprintf(stderr, "socat(stand-in): %s: %s\n", what, strerror(errno)); exit(1); } + +static void parse(const char *s, struct addr *o) { + memset(o, 0, sizeof *o); o->backlog = 5; o->v6only = -1; + char *copy = strdup(s), *save = NULL, *head = strtok_r(copy, ",", &save); + for (char *opt; (opt = strtok_r(NULL, ",", &save));) { + if (!strcasecmp(opt, "fork")) o->fork_ = 1; + else if (!strncasecmp(opt, "backlog=", 8)) o->backlog = atoi(opt + 8); + else if (!strncasecmp(opt, "ipv6only=", 9)) o->v6only = atoi(opt + 9); + } + char *colon = strchr(head, ':'); + if (!colon) { snprintf(o->kind, sizeof o->kind, "%s", head); return; } + *colon = 0; snprintf(o->kind, sizeof o->kind, "%s", head); + char *rest = colon + 1; + if (!strncasecmp(o->kind, "TCP", 3) && !strcasestr(o->kind, "LISTEN")) { + char *last = strrchr(rest, ':'); if (!last) { errno = EINVAL; die(s); } + *last = 0; snprintf(o->b, sizeof o->b, "%s", last + 1); + if (rest[0] == '[') { rest++; rest[strlen(rest) - 1] = 0; } + } + snprintf(o->a, sizeof o->a, "%s", rest); +} + +static int is_listen(const struct addr *a) { return strcasestr(a->kind, "LISTEN") != NULL; } + +static int listen_on(const struct addr *a) { + int fd, one = 1; + if (!strncasecmp(a->kind, "UNIX", 4)) { + struct sockaddr_un u = {.sun_family = AF_UNIX}; + if (strlen(a->a) >= sizeof u.sun_path) { errno = ENAMETOOLONG; die(a->a); } + strcpy(u.sun_path, a->a); + if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) die("socket"); + if (bind(fd, (struct sockaddr *)&u, sizeof u) < 0) die(a->a); + } else if (!strncasecmp(a->kind, "TCP6", 4)) { + struct sockaddr_in6 s = {.sin6_family = AF_INET6, .sin6_port = htons(atoi(a->a)), .sin6_addr = in6addr_any}; + if ((fd = socket(AF_INET6, SOCK_STREAM, 0)) < 0) die("socket"); + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + if (a->v6only >= 0) setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &a->v6only, sizeof a->v6only); + if (bind(fd, (struct sockaddr *)&s, sizeof s) < 0) die("bind"); + } else { + struct sockaddr_in s = {.sin_family = AF_INET, .sin_port = htons(atoi(a->a)), .sin_addr.s_addr = INADDR_ANY}; + if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) die("socket"); + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + if (bind(fd, (struct sockaddr *)&s, sizeof s) < 0) die("bind"); + } + if (listen(fd, a->backlog) < 0) die("listen"); + return fd; +} + +static void open_plain(const struct addr *a, int *in, int *out) { + if (!strcmp(a->kind, "-") || !strcasecmp(a->kind, "STDIO")) { *in = 0; *out = 1; return; } + int fd = -1; + if (!strncasecmp(a->kind, "UNIX", 4)) { + struct sockaddr_un u = {.sun_family = AF_UNIX}; + if (strlen(a->a) >= sizeof u.sun_path) { errno = ENAMETOOLONG; die(a->a); } + strcpy(u.sun_path, a->a); + if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) die("socket"); + if (connect(fd, (struct sockaddr *)&u, sizeof u) < 0) die(a->a); + } else if (!strncasecmp(a->kind, "TCP", 3)) { + struct addrinfo hints = {.ai_socktype = SOCK_STREAM, .ai_family = !strcasecmp(a->kind, "TCP4") ? AF_INET : !strcasecmp(a->kind, "TCP6") ? AF_INET6 : AF_UNSPEC}, *res, *r; + if (getaddrinfo(a->a, a->b, &hints, &res)) { errno = EHOSTUNREACH; die(a->a); } + for (r = res; r; r = r->ai_next) { + if ((fd = socket(r->ai_family, r->ai_socktype, r->ai_protocol)) < 0) continue; + if (!connect(fd, r->ai_addr, r->ai_addrlen)) break; + close(fd); fd = -1; + } + if (fd < 0) die(a->a); + } else { errno = EINVAL; die(a->kind); } + *in = *out = fd; +} + +static int pump(int from, int to) { + char buf[65536]; ssize_t n = read(from, buf, sizeof buf); + if (n <= 0) return 0; + for (ssize_t off = 0; off < n;) { ssize_t w = write(to, buf + off, n - off); if (w < 0) { if (errno == EINTR) continue; return 0; } off += w; } + return 1; +} + +static void relay(int in1, int out1, int in2, int out2) { + int open1 = 1, open2 = 1; + while (open1 || open2) { + struct pollfd p[2] = {{open1 ? in1 : -1, POLLIN, 0}, {open2 ? in2 : -1, POLLIN, 0}}; + if (poll(p, 2, -1) < 0) { if (errno == EINTR) continue; break; } + if (p[0].revents && !pump(in1, out2)) { open1 = 0; shutdown(out2, SHUT_WR); } + if (p[1].revents && !pump(in2, out1)) { open2 = 0; if (shutdown(out1, SHUT_WR) < 0 && out1 == 1) close(1); } + } +} + +int main(int argc, char **argv) { + signal(SIGPIPE, SIG_IGN); signal(SIGCHLD, SIG_IGN); + const char *pos[2]; int n = 0; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "-V")) { puts("socat stand-in, version 0"); return 0; } + if (argv[i][0] == '-' && argv[i][1]) continue; + if (n < 2) pos[n++] = argv[i]; + } + if (n != 2) { fprintf(stderr, "socat(stand-in): two addresses are needed\n"); return 1; } + struct addr a, b; parse(pos[0], &a); parse(pos[1], &b); + if (!is_listen(&a)) { int i1, o1, i2, o2; open_plain(&a, &i1, &o1); open_plain(&b, &i2, &o2); relay(i1, o1, i2, o2); return 0; } + int l = listen_on(&a); + for (;;) { + int c = accept(l, NULL, NULL); + if (c < 0) { if (errno == EINTR || errno == ECONNABORTED) continue; die("accept"); } + if (a.fork_) { pid_t pid = fork(); if (pid) { close(c); continue; } close(l); } + int i2, o2; open_plain(&b, &i2, &o2); relay(c, c, i2, o2); + return 0; + } +} diff --git a/wip/tools/stub-ok b/wip/tools/stub-ok new file mode 100755 index 000000000..85ef04424 --- /dev/null +++ b/wip/tools/stub-ok @@ -0,0 +1,10 @@ +#!/bin/sh +# Stands in for cl.exe and lib.exe in a cross-target `cargo check`: makes the +# files it is asked for, empty. +for a in "$@"; do + case "$a" in + -out:*|/out:*|/OUT:*|-OUT:*) : > "${a#*:}" ;; + -Fo*|/Fo*) : > "${a#???}" ;; + esac +done +exit 0 diff --git a/wip/tools/suite3.sh b/wip/tools/suite3.sh new file mode 100755 index 000000000..05b46df93 --- /dev/null +++ b/wip/tools/suite3.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# suite3.sh