diff --git a/.claude/hooks/require-nonroot-tests.sh b/.claude/hooks/require-nonroot-tests.sh index 3d2f925b8..09cadd5e6 100755 --- a/.claude/hooks/require-nonroot-tests.sh +++ b/.claude/hooks/require-nonroot-tests.sh @@ -7,6 +7,10 @@ # exits at its first check, so it costs nothing on a developer machine. # - Before replying, it makes the folders a test run writes in the checkout # writable for nobody. +# - The prefix it offers matches CI where it can. When nobody's own node has +# another major version than .nvmrc names, the prefix runs a copy of root's +# node of that major. When nobody cannot read the file NODE_EXTRA_CA_CERTS +# names, the prefix unsets the variable. # - .claude/settings.json registers the hook on PreToolUse for Bash with a # 10-second timeout. Claude Code cancels a slower hook and runs the command # anyway, so the hook must stay fast. @@ -31,17 +35,177 @@ fi # - Without node, or with a payload node cannot parse, node prints nothing. # Each read then fails and leaves its variable empty (`|| true` keeps set -e # from ending the hook), and an empty command lets the call through. -# - The script is single-quoted on purpose: its ${...} is JavaScript, not shell. +# - The script is single-quoted on purpose: its ${...} is JavaScript, not +# shell, and it writes a single quote as \u0027. +# - The command it prints has the text the command only passes as data +# blanked out, so the split below never reads a mention of a test run in +# a commit message, a script or a heredoc as a run: +# - Inside quotes, each ;, &, |, (, backtick and line break becomes a +# space (`git commit -m "... (npm test) ..."`, `node -e '/(npx|npm)/'`). +# - A heredoc's body is dropped (`python3 - <<'EOF'`), and so is a comment. +# - Both stay as written when a word of the command names bash, sh or zsh, +# because the shell runs that text (`bash -c 'cd /repo && npm test'`, +# `bash <<'EOF'`). A $( ) inside double quotes runs too, so its text is +# read as commands (`echo "$(npm test)"`). # shellcheck disable=SC2016 { IFS= read -r -d '' tool_command || true IFS= read -r -d '' payload_cwd || true } < <(node -e ' + const QUOTE = "\u0027" + const SEPARATORS = ";&|(`\n" + const SHELL_NAME = /(^|\/)(bash|sh|zsh)$/ + const HEREDOC_OPERATOR = /^<<(-?)[ \t]*(?:"([^"]*)"|\u0027([^\u0027]*)\u0027|([^\s;&|()<>]+))/ + + const blankSeparators = (text) => text.replace(/[;&|(`\n]/g, " ") + + const maskDataText = (command) => { + // A parser threading state character by character: output, position, + // the context stack, and the current word are all sequential state. + let output = "" + let index = 0 + let word = "" + let commandRunsShell = false + const pendingHeredocs = [] + // The bottom context is the command itself; "double" is double-quoted + // text; "substitution" is a $( ) inside double quotes. + const contexts = [{ kind: "command" }] + + const endWord = () => { + if (SHELL_NAME.test(word)) commandRunsShell = true + word = "" + } + const startCommand = () => { + word = "" + commandRunsShell = false + } + const readHeredocBodies = () => { + for (const heredoc of pendingHeredocs) { + while (index < command.length) { + const newlineIndex = command.indexOf("\n", index) + const lineEnd = newlineIndex === -1 ? command.length : newlineIndex + const line = command.slice(index, lineEnd) + const delimiterLine = heredoc.stripsTabs ? line.replace(/^\t+/, "") : line + index = lineEnd + 1 + if (delimiterLine === heredoc.delimiter) break + output += `${heredoc.bodyRuns ? line : ""}\n` + } + } + pendingHeredocs.length = 0 + } + + while (index < command.length) { + const character = command[index] + const next = command[index + 1] ?? "" + const context = contexts[contexts.length - 1] + + if (context.kind === "double") { + if (character === "\\") { + output += character + (commandRunsShell ? next : blankSeparators(next)) + index += 2 + continue + } + if (character === "\"") { + contexts.pop() + output += character + index += 1 + continue + } + if (character === "$" && next === "(") { + contexts.push({ kind: "substitution", depth: 0, word, commandRunsShell }) + startCommand() + output += "$(" + index += 2 + continue + } + output += commandRunsShell ? character : blankSeparators(character) + index += 1 + continue + } + + if (character === "\\") { + // A line continuation joins two lines into one command. + output += next === "\n" ? " " : character + blankSeparators(next) + word += next + index += 2 + continue + } + if (character === QUOTE) { + const closing = command.indexOf(QUOTE, index + 1) + const quoteEnd = closing === -1 ? command.length : closing + const quotedText = command.slice(index + 1, quoteEnd) + output += QUOTE + (commandRunsShell ? quotedText : blankSeparators(quotedText)) + output += closing === -1 ? "" : QUOTE + word += quotedText + index = quoteEnd + 1 + continue + } + if (character === "\"") { + contexts.push({ kind: "double" }) + output += character + index += 1 + continue + } + if (character === "#" && word === "") { + const lineEnd = command.indexOf("\n", index) + index = lineEnd === -1 ? command.length : lineEnd + continue + } + const heredocOperator = character === "<" && next === "<" ? HEREDOC_OPERATOR.exec(command.slice(index)) : null + if (heredocOperator) { + const [operatorText, tabsFlag, doubleQuoted, singleQuoted, bare] = heredocOperator + pendingHeredocs.push({ + delimiter: doubleQuoted ?? singleQuoted ?? bare, + stripsTabs: tabsFlag === "-", + bodyRuns: commandRunsShell || SHELL_NAME.test(word), + }) + output += operatorText + index += operatorText.length + continue + } + if (character === "\n") { + output += character + index += 1 + readHeredocBodies() + startCommand() + continue + } + if (context.kind === "substitution" && character === ")") { + if (context.depth === 0) { + contexts.pop() + word = context.word + commandRunsShell = context.commandRunsShell + output += character + index += 1 + continue + } + context.depth -= 1 + } + if (SEPARATORS.includes(character)) { + if (context.kind === "substitution" && character === "(") context.depth += 1 + output += character + index += 1 + startCommand() + continue + } + if (/\s/.test(character)) { + endWord() + output += character + index += 1 + continue + } + word += character + output += character + index += 1 + } + return output + } + const chunks = [] // Buffer.concat runs before toString, because a multi-byte character can span two chunks. process.stdin.on("data", (chunk) => chunks.push(chunk)).on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) - process.stdout.write(`${parsed.tool_input?.command ?? ""}\0${parsed.cwd ?? ""}\0`) + process.stdout.write(`${maskDataText(parsed.tool_input?.command ?? "")}\0${parsed.cwd ?? ""}\0`) }) ' 2>/dev/null) @@ -69,41 +233,59 @@ flags_with_optional_argument='([[:space:]]+-[^[:space:]]*([[:space:]]+[^-[:space # out test:remote-boot and test:cli-pty, the two suites exempt from this # hook because they cannot run as nobody; the exemptions below give each # reason. -vitest_binary='([^[:space:]]*/)?vitest(@[^[:space:]]*)?' -vitest_runner="(npx${flags_with_optional_argument}[[:space:]]+)?${vitest_binary}|node[[:space:]]+[^[:space:]]*vitest\.mjs" -npm_exec_runner="npm${flags_with_optional_argument}[[:space:]]+(exec|x)${flags_with_optional_argument}[[:space:]]+${vitest_binary}" -npm_runner="npm${flags_with_optional_argument}[[:space:]]+(test|t|run(-script)?${flags_with_optional_argument}[[:space:]]+(test|test:coverage|test:watch|snapshot:update))" +# vitest, npm, npx and node may be named by path (`/usr/local/bin/npm test`), +# as the wrappers below may. A folder holding = is a setting's value, not a +# command's path, so `FOO=/x/npm test` runs test, not npm. +optional_command_folder='([^[:space:]=]*/)?' +vitest_binary="${optional_command_folder}vitest(@[^[:space:]]*)?" +vitest_runner="(${optional_command_folder}npx${flags_with_optional_argument}[[:space:]]+)?${vitest_binary}|${optional_command_folder}node[[:space:]]+[^[:space:]]*vitest\.mjs" +npm_exec_runner="${optional_command_folder}npm${flags_with_optional_argument}[[:space:]]+(exec|x)${flags_with_optional_argument}[[:space:]]+${vitest_binary}" +npm_runner="${optional_command_folder}npm${flags_with_optional_argument}[[:space:]]+(test|t|run(-script)?${flags_with_optional_argument}[[:space:]]+(test|test:coverage|test:watch|snapshot:update))" test_runner="${vitest_runner}|${npm_exec_runner}|${npm_runner}" -# variable_setting matches a VAR=value setting before the command -# (`CI=1 npm test`), with the value in one of three forms. -# - double_quoted matches text in double quotes, with no " inside. +# variable_setting matches a VAR=value or VAR+=value setting before the +# command (`CI=1 npm test`). Its value is a shell_word: unquoted text with +# any number of quoted parts, each followed by more unquoted text, as in the +# reply's own prefix (`PATH=/copy:"$PATH"`). +# - double_quoted matches text in double quotes, where a backslash escapes +# the next character (`"a\"b"`). # - single_quoted matches text in single quotes, with no ' inside. -# - unquoted matches text up to the first space, ' or ". -double_quoted='"[^"]*"' +# - unquoted matches text up to the first space, ' or " that no backslash +# escapes. The reply's prefix escapes a space in the checkout's path +# (`HOME=/my\ repo/node_modules/.nobody-home`). +double_quoted='"([^"\\]|\\.)*"' single_quoted="'[^']*'" -unquoted="[^[:space:]'\"]*" -variable_setting="[A-Za-z_][A-Za-z0-9_]*=(${double_quoted}|${single_quoted}|${unquoted})[[:space:]]+" +unquoted="([^[:space:]'\"\\\\]|\\\\.)*" +shell_word="${unquoted}((${double_quoted}|${single_quoted})${unquoted})*" +variable_setting="[A-Za-z_][A-Za-z0-9_]*\\+?=${shell_word}[[:space:]]+" # wrapper matches these commands, which run the command after them: # - env and time, with flags that take no argument, because a flag's argument -# would look the same as the command it runs. +# would look the same as the command it runs. env's -u (--unset) and -C +# (--chdir) are the exceptions: each always takes a shell_word, also when it +# ends a group of env's flags that take none, -0, -i and -v +# (`env -iu NAME npm test`). The reply's own prefix in a cloud container +# uses -u (`env -u NODE_EXTRA_CA_CERTS npm test`). # - exec and command, without flags, so `command -v vitest` is not a run. # - timeout, whose flags may each take one argument, then the duration # (`timeout -s KILL 600 npm test`, `timeout -k 5 600 npm test`). # - bash, sh and zsh with their flags, where a group of one-letter flags that # holds o or O takes an argument (`bash -euo pipefail -c`, # `bash -O extglob -c`), then the opening quote of the command they run and -# any spaces after it (`bash -lc 'npm test'`). The split below ignores -# quotes, so a run or a cd after ; or && inside the quoted text counts too -# (`bash -c 'npm ci && cd /repo && npm test'`), and a mention there -# (`bash -c 'echo npm test'`) does not. +# any spaces after it (`bash -lc 'npm test'`). The quoted text a shell runs +# reaches the split as written, so a run or a cd after ; or && inside it +# counts too (`bash -c 'npm ci && cd /repo && npm test'`), and a mention +# there (`bash -c 'echo npm test'`) does not. +# - Each of env, time, timeout and the shells may be named by path +# (`/usr/bin/env npm test`), as the reply's own prefix names env. exec and +# command are shell builtins, so they have no path. # setpriv is not a wrapper here: a run through setpriv is the nobody run the # reply asks for, so the runner after it never counts. -timeout_wrapper="timeout${flags_with_optional_argument}[[:space:]]+[0-9][^[:space:]]*" +env_flags="([[:space:]]+((-[0iv]*[uC]|--unset|--chdir)[[:space:]]+${shell_word}|-[^[:space:]]*))*" +timeout_wrapper="${optional_command_folder}timeout${flags_with_optional_argument}[[:space:]]+[0-9][^[:space:]]*" shell_flags='([[:space:]]+([-+][[:alpha:]]*[oO][[:alpha:]]*[[:space:]]+[^[:space:]]+|[-+][^[:space:]]*))*' -shell_wrapper="(bash|sh|zsh)${shell_flags}[[:space:]]+([\"'][[:space:]]*)?" -wrapper="((env|time)${flags_without_argument}|exec|command|${timeout_wrapper})[[:space:]]+|${shell_wrapper}" +shell_wrapper="${optional_command_folder}(bash|sh|zsh)${shell_flags}[[:space:]]+([\"'][[:space:]]*)?" +wrapper="(${optional_command_folder}env${env_flags}|${optional_command_folder}time${flags_without_argument}|exec|command|${timeout_wrapper})[[:space:]]+|${shell_wrapper}" # shell_keyword matches a shell keyword a command can follow: if # (`if npm test`), then, do, else, elif, while, until, ! (`! npm test`) and { @@ -116,7 +298,8 @@ command_prefix="(${wrapper}|${shell_keyword}|${variable_setting})*" # followed only by spaces and then a redirection (`bash -c 'npm test' 2>`), a # ) (`$(bash -c 'npm test')`), a comment or the end. So `npm run testx` and # `npm run test:cli-pty` are not runs, and neither is the `vitest' src/` that -# `grep -E 'npm test|vitest' src/` splits into. +# a shell's quoted `grep -E 'npm test|vitest' src/` splits into. Inside the +# double quotes, `\\)` reaches the regex as `\)`, a literal ). runner_end="([[:space:]]|\\)|[\"'][[:space:]]*([0-9]*[<>]|\\)|$)|[\"'][[:space:]]+#|$)" # A command that runs tests starts with the runner, after any prefix. A @@ -153,19 +336,23 @@ exempt_suite_run="${vitest_exempt_suite_run}|${npm_exempt_suite_run}" # test'`) is cut off first, with leading_command_prefix, so the first three # patterns start at cd or pushd and the path is the second group of their # match. any_cd accepts that prefix itself for the grep below. -# - cd_to_absolute_path matches an unquoted path that starts with /. +# - cd_to_absolute_path matches an unquoted path that starts with / or \/. +# In it, and in cd_to_relative_path, a backslash escapes the next +# character (`cd /my\ repo`), and the walk drops the backslash. # - cd_to_quoted_absolute_path matches a quoted path that starts with / and # holds no $, backtick or ~, so the shell would use it as written. # - cd_to_relative_path matches an unquoted relative path. Its first -# character cannot be - (`cd -` or a flag), # (a comment, which leaves a -# bare `cd`), or ~, $ or a quote, which only the shell can expand. +# character cannot be - or \- (`cd -` or a flag), \/ (an absolute path), +# # (a comment, which leaves a bare `cd`), or ~, $ or a quote, which only +# the shell can expand. # - any_cd matches any cd, pushd or popd. The walk tries it last, so it # catches the rest: a bare `cd`, with or without a comment after it, # `cd -`, popd, and a ~, $ or quoted path the others do not take. directory_change="^[[:space:]]*(cd|pushd)[[:space:]]+" -cd_to_absolute_path="${directory_change}(/[^[:space:]]*)" +escaped_path_character='([^[:space:]\\]|\\.)' +cd_to_absolute_path="${directory_change}(\\\\?/${escaped_path_character}*)" cd_to_quoted_absolute_path="${directory_change}[\"'](/[^\"'\$\`~]*)[\"']" -cd_to_relative_path="${directory_change}([^-~#\"'\$[:space:]][^[:space:]]*)" +cd_to_relative_path="${directory_change}(([^-~#\"'\$[:space:]\\\\]|\\\\[^-/])${escaped_path_character}*)" leading_command_prefix="^[[:space:]]*${command_prefix}" any_cd="^[[:space:]]*${command_prefix}(cd|pushd|popd)([[:space:]]|$)" @@ -211,8 +398,9 @@ cd_or_test_commands="$(printf '%s\n' "${tool_command}" | # resolve. A cd after the run does not count. # - A cd after env, exec or timeout moves it too, though the shell's folder # stays put: those commands run a program, and cd is a shell builtin. -# - npm's --prefix folder is not followed: `npm --prefix /other/checkout -# test` counts as `npm test`. +# - npm's --prefix folder and env's -C folder are not followed: +# `npm --prefix /other/checkout test` and `env -C /other/checkout npm +# test` count as `npm test`. session_dir="${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" run_dir="${session_dir}" current_dir="${session_dir}" @@ -223,10 +411,15 @@ root_test_runs="" while IFS= read -r split_command; do cd_candidate="${split_command}" if [[ "${split_command}" =~ ${leading_command_prefix} ]]; then + # Keep the text after the matched prefix: ${var:offset} starts at the + # match's length. cd_candidate="${split_command:${#BASH_REMATCH[0]}}" fi + # read without -r drops each escaping backslash, as the shell does, and as + # a builtin it costs no fork on a command with thousands of cds. if [[ "${cd_candidate}" =~ ${cd_to_absolute_path} ]]; then - current_dir="${BASH_REMATCH[2]}" + # shellcheck disable=SC2162 + IFS= read current_dir <<<"${BASH_REMATCH[2]}" continue fi if [[ "${cd_candidate}" =~ ${cd_to_quoted_absolute_path} ]]; then @@ -234,7 +427,9 @@ while IFS= read -r split_command; do continue fi if [[ "${cd_candidate}" =~ ${cd_to_relative_path} ]]; then - current_dir="${current_dir}/${BASH_REMATCH[2]}" + # shellcheck disable=SC2162 + IFS= read relative_path <<<"${BASH_REMATCH[2]}" + current_dir="${current_dir}/${relative_path}" continue fi if [[ "${cd_candidate}" =~ ${any_cd} ]]; then @@ -278,11 +473,20 @@ fi nobody_uid="$(id -u nobody)" nobody_gid="$(id -g nobody)" -# The prefix runs whichever node nobody finds on PATH. nobody cannot read -# root's home, where nvm installs Node, so that node can be another version -# than the .nvmrc one CI runs, or missing. Without one there is no command to -# offer, so the run goes through, as it does without setpriv or a nobody user. -nobody_node_version="$(setpriv --reuid="${nobody_uid}" --regid="${nobody_gid}" --clear-groups node --version 2>/dev/null)" || nobody_node_version="" +# By default the prefix runs whichever node nobody finds on the session's +# PATH. nvm installs Node under root's home, which nobody cannot read, so +# nobody's node can be another version than the .nvmrc one CI runs, or +# missing. +# - With no node at all, nobody most likely has no npx either, because npx +# installs beside node, and the node copy made below holds only node. There +# is then no command to offer, so the run goes through, as it does without +# setpriv or a nobody user. +# - The probe searches the PATH Claude Code gives the hook, and env searches +# it as nobody, as the prefix's env does. setpriv keeps root's capabilities +# until it starts its command, so a search by setpriv itself reaches a node +# under root's home that nobody's runs cannot. /usr/bin/env is named in +# full so setpriv searches no PATH. +nobody_node_version="$(setpriv --reuid="${nobody_uid}" --regid="${nobody_gid}" --clear-groups /usr/bin/env node --version 2>/dev/null)" || nobody_node_version="" if [[ -z "${nobody_node_version}" ]]; then exit 0 @@ -297,7 +501,6 @@ nobody_writable=( "${checkout}/node_modules/.nobody-home" "${checkout}/coverage" ) -mkdir -p "${nobody_writable[@]}" # snapshot:update rewrites the tool-surface baseline in this folder, the only # snapshot folder in the repo. git tracks neither owner nor write bits, so the @@ -308,6 +511,8 @@ if [[ -d "${snapshots_dir}" ]]; then nobody_writable+=("${snapshots_dir}") fi +mkdir -p "${nobody_writable[@]}" + # The folders go to nobody and are not writable by group or other users, # because vitest runs code from its caches and checks test output against the # snapshots. Both commands recurse, so the files a root run left inside also @@ -315,28 +520,156 @@ fi chown -R "${nobody_uid}:${nobody_gid}" "${nobody_writable[@]}" chmod -R go-w "${nobody_writable[@]}" +# node_major_differs is true when .nvmrc names a number (24 or v24.1.0), not +# an alias such as lts/*, and nobody's node has another major version. +nvmrc_version="$(cat "${checkout}/.nvmrc" 2>/dev/null)" || nvmrc_version="" +# Drop any leading spaces, tabs or line breaks, then cut the text at the +# next one, so a CRLF line end or a comment after the version does not hide +# it. +nvmrc_version="${nvmrc_version#"${nvmrc_version%%[![:space:]]*}"}" +nvmrc_version="${nvmrc_version%%[[:space:]]*}" +nvmrc_major="${nvmrc_version#v}" +nvmrc_major="${nvmrc_major%%.*}" +nobody_node_major="${nobody_node_version#v}" +nobody_node_major="${nobody_node_major%%.*}" +node_major_differs=false + +if [[ "${nvmrc_major}" =~ ^[0-9]+$ && "${nobody_node_major}" != "${nvmrc_major}" ]]; then + node_major_differs=true +fi + +# When node_major_differs, the prefix runs a copy of a node root has +# installed whose major version .nvmrc names. nobody cannot run the original, +# because nvm installs it under root's home. +# - The hook checks its own node first. Claude Code starts hooks with its own +# PATH, not the session's, so in a cloud container that node is often the +# container's. The hook then looks where nvm keeps its versions ($NVM_DIR, +# then ~/.nvm, often the same folder) and takes the newest of that major in +# the first folder that has one. +# - The copy sits in a folder named by version, so a later run reuses it and a +# different version gets its own folder. cp -L copies the binary a symlink +# points to. +# - The folder stays root's, outside the folders handed to nobody: root never +# writes where nobody could plant a symlink, and nobody can run the copy but +# not replace it. +# - The prefix puts the copy's folder first on PATH. nobody's runs then find +# the copy, and so do the npx and tsx scripts a run starts, whose +# `#!/usr/bin/env node` lines look node up on PATH. npx and npm themselves +# still come from the session's PATH. +nobody_node_dir="" + +if [[ "${node_major_differs}" == true ]]; then + nvmrc_node_path="" + nvmrc_node_version="" + hook_node_version="$(node --version 2>/dev/null)" || hook_node_version="" + hook_node_major="${hook_node_version#v}" + hook_node_major="${hook_node_major%%.*}" + + if [[ "${hook_node_major}" == "${nvmrc_major}" ]]; then + # node names the binary it runs from. The node on PATH can be a version + # manager's shim (asdf, mise, volta) that starts it, and a copy of the + # shim fails as nobody, who cannot read the version manager's files. + nvmrc_node_path="$(node -p process.execPath 2>/dev/null)" || nvmrc_node_path="" + nvmrc_node_version="${hook_node_version}" + else + for nvm_dir in "${NVM_DIR:-}" "${HOME:-}/.nvm"; do + [[ -n "${nvm_dir}" && -d "${nvm_dir}/versions/node" ]] || continue + # nvm keeps each node three folders below versions/node, in a folder + # named by its version: /versions/node/v24.11.0/bin/node. + # find runs from inside versions/node, so -path sees only those three + # folders: a v24. elsewhere in the nvm folder's own path cannot match. + # CDPATH is cleared so cd enters the folder the -d test checked, and + # cd's error on a folder it cannot enter is dropped, as find's is. + # sort -V orders v24.9.0 before v24.10.0, so the last line is the newest. + # -L enters a versions/node or version folder that is a symlink. + # find exits non-zero on a folder it cannot read or a symlink loop that + # -L walks into, while still listing the nodes it reached, so || true + # keeps that list rather than let set -e end the hook, which would let + # the run through as root. + newest_node="$(CDPATH='' cd "${nvm_dir}/versions/node" 2>/dev/null && find -L . -mindepth 3 -maxdepth 3 -path "./v${nvmrc_major}.*/bin/node" 2>/dev/null | sort -V | tail -n 1)" || true + [[ -n "${newest_node}" ]] || continue + nvmrc_node_path="${nvm_dir}/versions/node/${newest_node#./}" + nvmrc_node_version="$(basename "$(dirname "$(dirname "${nvmrc_node_path}")")")" + break + done + fi + + if [[ -n "${nvmrc_node_path}" ]]; then + node_copies_dir="${checkout}/node_modules/.nobody-node" + nobody_node_dir="${node_copies_dir}/node-${nvmrc_node_version}" + + # A node already at this name is reused. Only the mv below puts one + # there, so it is a whole copy. + if [[ ! -x "${nobody_node_dir}/node" ]]; then + mkdir -p "${nobody_node_dir}" + # 755 whatever root's umask, so nobody can reach and run the copy. + chmod 755 "${node_copies_dir}" "${nobody_node_dir}" + # cp makes its file executable before writing it, so a copy cut short by + # the hook's timeout, or one a concurrent hook is still writing, would + # pass the -x check above. The copy gets a temporary name, and mv's + # rename puts only a whole binary at the name later runs reuse. + # A failed cp (a full disk) ends the hook as set -e would, after + # removing its partial file, which would otherwise hold the space. + cp -L "${nvmrc_node_path}" "${nobody_node_dir}/node.$$" || { + rm -f "${nobody_node_dir}/node.$$" + exit 1 + } + chmod 755 "${nobody_node_dir}/node.$$" + mv -f "${nobody_node_dir}/node.$$" "${nobody_node_dir}/node" + fi + fi +fi + # %q quotes the HOME path for the shell, so the prefix still works when the # checkout's path holds a space. A path of plain characters prints unchanged. printf -v nobody_home_for_shell '%q' "${checkout}/node_modules/.nobody-home" +# env's arguments, joined with spaces in the reply. The -u option goes first, +# because env reads options only before its first NAME=value setting. +env_arguments=() + +# Claude Code's cloud containers point NODE_EXTRA_CA_CERTS at a file under +# root's home. Every node nobody starts then prints a warning that it cannot +# read the file, which breaks tests that check a child process's exact stderr. +# The suites this prefix runs make no network calls, so the prefix unsets the +# variable. +# - /bin/sh is named in full so setpriv searches no PATH. +# - The script's $1 is single-quoted on purpose: the inner shell expands it, +# not this one. The `sh` after the script is the inner shell's $0, so the +# file's path lands in $1. +# shellcheck disable=SC2016 +if [[ -n "${NODE_EXTRA_CA_CERTS:-}" ]] && + ! setpriv --reuid="${nobody_uid}" --regid="${nobody_gid}" --clear-groups /bin/sh -c 'test -r "$1"' sh "${NODE_EXTRA_CA_CERTS}"; then + env_arguments+=(-u NODE_EXTRA_CA_CERTS) +fi + +env_arguments+=("HOME=${nobody_home_for_shell}") + +# $PATH stays literal, so the shell running the prefix expands it to the +# session's PATH, which env searches after the copy's folder. A folder there +# that nobody cannot read is skipped. +# - The double quotes keep that PATH one argument when a folder in it holds a +# space, as Windows folders on WSL's PATH do. Unquoted, the shell would +# split it, and env would take the text after the space as its command. +if [[ -n "${nobody_node_dir}" ]]; then + printf -v nobody_node_dir_for_shell '%q' "${nobody_node_dir}" + env_arguments+=("PATH=${nobody_node_dir_for_shell}:\"\$PATH\"") +fi + +# The prefix names /usr/bin/env in full for the reason the probe above does: +# setpriv still has root's capabilities when it searches PATH, so a bare env +# could resolve to one nobody's own search would never reach. cat >&2 </dev/null)" || nvmrc_version="" -nvmrc_version="${nvmrc_version%%[[:space:]]*}" -nvmrc_major="${nvmrc_version#v}" -nvmrc_major="${nvmrc_major%%.*}" -nobody_node_major="${nobody_node_version#v}" -nobody_node_major="${nobody_node_major%%.*}" - -if [[ "${nvmrc_major}" =~ ^[0-9]+$ && "${nobody_node_major}" != "${nvmrc_major}" ]]; then +if [[ -n "${nobody_node_dir}" ]]; then + echo "nobody's own node is ${nobody_node_version}, so the prefix runs a copy of node ${nvmrc_node_version}, whose major version .nvmrc names and CI runs." >&2 +elif [[ "${node_major_differs}" == true ]]; then + # The majors differ, but root has no node of the .nvmrc major to copy. echo "nobody's node is ${nobody_node_version}, not the ${nvmrc_version} that .nvmrc names and CI runs, so a result can differ from CI's." >&2 fi diff --git a/AGENTS.md b/AGENTS.md index fbc9969fd..8225e8498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ render.yaml # Render Blueprint; stays at the repo root, the only place Render re Dockerfile # Two-target build: local (default) + remote .claude/ # Committed Claude Code hook setup only (the rest is gitignored): settings.json registers the two hooks below hooks/install-deps.sh # Runs at session start and worktree entry: loads nvm, installs dependencies when needed (npm ci, sst install), and at session start puts the checkout's Node (the .nvmrc version when nvm has it, else nvm's default alias) first on PATH for the session's later Bash commands - hooks/require-nonroot-tests.sh # Runs before each Bash command: blocks a root session's test runs, makes the folders they write writable for the nobody user, and replies to Claude with the setpriv prefix that reruns the command as nobody + hooks/require-nonroot-tests.sh # Runs before each Bash command: blocks a root session's test runs, makes the folders they write writable for the nobody user, and replies to Claude with the setpriv prefix that reruns the command as nobody. When nobody's own Node has another major version than .nvmrc names, the prefix runs a copy of root's Node of that major, if root has one obsidian-headless/ # Lockfile-pinned obsidian-headless Sync CLI for the :remote image rootfs/ # Container filesystem overlay for the :remote image: s6 init chain and services in etc/s6-overlay/, and usr/local/bin/get-sync-token, an in-container terminal sign-in to Obsidian that prints the Sync token for .env templates/memory/ # About Me/ memory file templates for new vaults diff --git a/src/__tests__/require-nonroot-tests.test.ts b/src/__tests__/require-nonroot-tests.test.ts index 98f8ff195..f53d2ba9e 100644 --- a/src/__tests__/require-nonroot-tests.test.ts +++ b/src/__tests__/require-nonroot-tests.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -20,10 +21,13 @@ import { describe, expect, it, onTestFinished } from "vitest" * Behavioral spec for the Claude Code hook that stops test runs in a root * session (.claude/hooks/require-nonroot-tests.sh): which commands it stops, * which checkout's folders it prepares, and the reply it gives. The real hook - * runs under `bash` with real node and git, and with stub `id`, `setpriv` and - * `chown` executables first on PATH. The stub `id` poses as root and reports - * the test runner's own ids for nobody, so the hook's chown needs no - * privileges. + * runs under `bash` with real git, and with stub `id`, `setpriv`, `chown` and + * `node` executables first on PATH. + * - The stub `id` poses as root and reports the test runner's own ids for + * nobody, so the hook's chown needs no privileges. + * - The stub `node` answers the version and binary-path questions the hook + * asks of its own node and hands every other call, such as the payload + * parse, to the real node. */ const HOOK_PATH = resolve(import.meta.dirname, "../../.claude/hooks/require-nonroot-tests.sh") @@ -53,7 +57,12 @@ esac /** Stub `setpriv`: drops its own options and runs the rest of the command * with STUB_NOBODY_PATH as PATH, standing in for the PATH entries nobody - * can read. */ + * can read. + * - A bare command name is looked up on the PATH the stub was given, before + * the switch. Real setpriv keeps root's capabilities until it starts its + * command, so its own search reaches folders nobody cannot read. + * - A command named by path runs as named, and searches nobody's PATH + * itself (`/usr/bin/env node`). */ const SETPRIV_STUB = `#!/bin/sh while [ "$#" -gt 0 ]; do case "$1" in @@ -61,9 +70,14 @@ while [ "$#" -gt 0 ]; do *) break ;; esac done +case "$1" in + */*) command_path="$1" ;; + *) command_path="$(command -v "$1")" || exit 127 ;; +esac +shift PATH="$STUB_NOBODY_PATH" export PATH -exec "$@" +exec "$command_path" "$@" ` /** Stub `chown`: appends its arguments to STUB_CHOWN_LOG, one call per line, @@ -80,9 +94,38 @@ const NOBODY_NODE_STUB = `#!/bin/sh echo "$STUB_NOBODY_NODE_VERSION" ` -/** The fixture checkout's .nvmrc, and a nobody node version that matches it. */ +/** The session's node: prints STUB_SESSION_NODE_VERSION for --version, and + * for `-p process.execPath` STUB_SESSION_NODE_BINARY when set (as a version + * manager's shim reports the node it starts), else its own path. It hands + * every other call (the hook's payload parsing) to the real node at + * STUB_REAL_NODE. */ +const SESSION_NODE_STUB = `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "$STUB_SESSION_NODE_VERSION" + exit 0 +fi +if [ "$1 $2" = "-p process.execPath" ]; then + echo "\${STUB_SESSION_NODE_BINARY:-$0}" + exit 0 +fi +exec "$STUB_REAL_NODE" "$@" +` + +/** A `cp` cut short, as by the hook's timeout: like the real cp, it makes + * its last argument executable before writing it, then stops partway. */ +const CUT_SHORT_CP_STUB = `#!/bin/sh +for destination do :; done +: > "$destination" +chmod 755 "$destination" +printf '#!/bin/sh\\n# partial' > "$destination" +exit 1 +` + +/** The fixture checkout's .nvmrc, and a node version that matches it. */ const NVMRC_VERSION = "24" const MATCHING_NODE_VERSION = "v24.11.0" +/** A node version whose major differs from the fixture's .nvmrc. */ +const OTHER_NODE_VERSION = "v22.22.0" const SNAPSHOTS_FOLDER = "src/vault-mcp/mcp-core/__tests__/__snapshots__" @@ -119,6 +162,21 @@ type HookRunOptions = { /** What nobody's node prints for --version. Defaults to a version that * matches the fixture's .nvmrc. */ nobodyNodeVersion?: string + /** What the session's own node prints for --version. Defaults to a + * version that matches the fixture's .nvmrc. */ + sessionNodeVersion?: string + /** The binary the session's own node reports as process.execPath. + * Defaults to the stub itself. */ + sessionNodeBinary?: string + /** The hook's NODE_EXTRA_CA_CERTS. Unset by default. */ + extraCaCerts?: string + /** The hook's NVM_DIR. Unset by default, so the hook looks only in ~/.nvm. */ + nvmDir?: string + /** Whether the hook gets a HOME. Defaults to true. */ + hasHome?: boolean + /** The umask the hook starts with, as `umask` takes it. Defaults to the + * runner's own. */ + umask?: string claudeProjectDir?: string /** The hook's PATH. Defaults to the stubs ahead of the runner's PATH. */ path?: string @@ -173,6 +231,7 @@ const createHookFixture = (): HookFixture => { writeExecutable(join(stubBinDir, "setpriv"), SETPRIV_STUB) writeExecutable(join(stubBinDir, "chown"), CHOWN_STUB) writeExecutable(join(nobodyBinDir, "node"), NOBODY_NODE_STUB) + writeExecutable(join(stubBinDir, "node"), SESSION_NODE_STUB) execFileSync("git", ["init", "--quiet", checkout], { env: { PATH: runnerPath(), HOME: outsideDir }, @@ -244,21 +303,30 @@ const hookEnv = (options: Omit): Record // own environment never reaches the hook. return { PATH: options.path ?? `${fixture.stubBinDir}:${runnerPath()}`, - HOME: fixture.outsideDir, + ...(options.hasHome === false ? {} : { HOME: fixture.outsideDir }), STUB_SESSION_UID: String(options.sessionUid ?? 0), STUB_NOBODY_EXISTS: options.nobodyExists === false ? "0" : "1", STUB_NOBODY_UID: String(uid), STUB_NOBODY_GID: String(gid), STUB_NOBODY_PATH: options.nobodyHasNode === false ? fixture.emptyBinDir : fixture.nobodyBinDir, STUB_NOBODY_NODE_VERSION: options.nobodyNodeVersion ?? MATCHING_NODE_VERSION, + STUB_SESSION_NODE_VERSION: options.sessionNodeVersion ?? MATCHING_NODE_VERSION, + STUB_REAL_NODE: process.execPath, STUB_CHOWN_LOG: fixture.chownLog, STUB_REAL_CHOWN: realChownPath(), ...(options.claudeProjectDir ? { CLAUDE_PROJECT_DIR: options.claudeProjectDir } : {}), + ...(options.extraCaCerts ? { NODE_EXTRA_CA_CERTS: options.extraCaCerts } : {}), + ...(options.nvmDir ? { NVM_DIR: options.nvmDir } : {}), + ...(options.sessionNodeBinary ? { STUB_SESSION_NODE_BINARY: options.sessionNodeBinary } : {}), } } const runHook = (options: HookRunOptions): HookRun => { - const result = spawnSync("bash", [HOOK_PATH], { + // The script's "$0" is the hook's path, which bash -c takes after the script. + const hookArguments = options.umask + ? ["-c", `umask ${options.umask} && exec bash "$0"`, HOOK_PATH] + : [HOOK_PATH] + const result = spawnSync("bash", hookArguments, { // Outside any checkout, so the hook's "." fallback can never reach the // repo running these tests. cwd: options.fixture.outsideDir, @@ -280,25 +348,58 @@ const runCommandThroughHook = (fixture: HookFixture, command: string): HookRun = } /** The hook's reply to a stopped run, test-owned so a change to the prefix - * or the wording shows up here. nobodyHomeInPrefix is the HOME path as the - * prefix spells it: the plain path, unless the path needs quoting. */ + * or the wording shows up here. + * - nobodyHomeInPrefix is the HOME path as the prefix spells it: the plain + * path, unless the path needs quoting. + * - unsetsExtraCaCerts adds env's -u NODE_EXTRA_CA_CERTS. + * - nobodyNodeDir adds that folder first on PATH. */ const expectedRefusal = ( checkout: string, { nobodyHomeInPrefix = `${checkout}/node_modules/.nobody-home`, - }: { nobodyHomeInPrefix?: string } = {}, + unsetsExtraCaCerts = false, + nobodyNodeDir, + }: { nobodyHomeInPrefix?: string; unsetsExtraCaCerts?: boolean; nobodyNodeDir?: string } = {}, ): string => { const { uid, gid } = runnerIds() + const unsetSetting = unsetsExtraCaCerts ? "-u NODE_EXTRA_CA_CERTS " : "" + const pathSetting = nobodyNodeDir ? ` PATH=${nobodyNodeDir}:"$PATH"` : "" return [ "Tests do not run as root in this repo: root reads the files the permission tests make unreadable, so those tests fail here but pass in CI.", "Run the same test command as the nobody user, with this prefix in front of the test command itself (after any cd):", - ` setpriv --reuid=${uid} --regid=${gid} --clear-groups env HOME=${nobodyHomeInPrefix}`, + ` setpriv --reuid=${uid} --regid=${gid} --clear-groups /usr/bin/env ${unsetSetting}HOME=${nobodyHomeInPrefix}${pathSetting}`, `The folders nobody needs to write in ${checkout} are ready.`, "", ].join("\n") } +/** The reply's line when the prefix runs a copy of node. */ +const expectedNodeCopyLine = ({ + nobodyNodeVersion, + copiedNodeVersion, +}: { + nobodyNodeVersion: string + copiedNodeVersion: string +}): string => { + return `nobody's own node is ${nobodyNodeVersion}, so the prefix runs a copy of node ${copiedNodeVersion}, whose major version .nvmrc names and CI runs.\n` +} + +/** The folder the hook copies a node of this version into. */ +const nodeCopyFolder = (checkout: string, nodeVersion: string): string => { + return join(checkout, "node_modules", ".nobody-node", `node-${nodeVersion}`) +} + +/** Lays out nvm's folders under nvmDir, one stub node per version, each + * printing its version so a copy shows which one it came from. */ +const writeNvmNodes = (nvmDir: string, nodeVersions: readonly string[]): void => { + for (const nodeVersion of nodeVersions) { + const binDir = join(nvmDir, "versions", "node", nodeVersion, "bin") + mkdirSync(binDir, { recursive: true }) + writeExecutable(join(binDir, "node"), `#!/bin/sh\n# node ${nodeVersion}\n`) + } +} + const scratchFolders = (checkout: string): string[] => [ join(checkout, "node_modules", ".vite-temp"), join(checkout, "node_modules", ".vitest"), @@ -404,12 +505,63 @@ describe("require-nonroot-tests hook", () => { { label: "node running vitest.mjs", command: "node node_modules/vitest/vitest.mjs run" }, { label: "npm run snapshot:update", command: "npm run snapshot:update" }, { label: "a subshell", command: "(cd sub; npm test)" }, + { label: "a line after a comment holding an apostrophe", command: "# it's a run\nnpm test" }, + { label: "a bash heredoc", command: "bash <<'EOF'\nnpm test\nEOF" }, + { label: "a line after a cat heredoc", command: "cat <notes\nhello\nEOF\nnpm test" }, + { label: "a command substitution inside double quotes", command: 'echo "$(npm test)"' }, { label: "a subshell inside command substitution", command: "out=$( (npm test) )" }, { label: "a backtick substitution", command: "out=`npm test 2>&1`" }, { label: "a variable setting before npx", command: "CI=1 npx vitest run" }, { label: "env with a variable setting", command: "env CI=1 npm test" }, { label: "env with a flag", command: "env -i npm test" }, + { + label: "env by its path, as the reply's prefix names it", + command: "/usr/bin/env HOME=/repo/node_modules/.nobody-home npm test", + }, + { label: "timeout by its path", command: "/usr/bin/timeout 600 npm test" }, + { label: "a shell by its path", command: "/bin/bash -c 'npm test'" }, + { + label: "env with -u and the name it unsets", + command: "env -u NODE_EXTRA_CA_CERTS npm test", + }, + { + label: "the reply's full prefix without setpriv", + command: + '/usr/bin/env -u NODE_EXTRA_CA_CERTS HOME=/repo/node_modules/.nobody-home PATH=/repo/node_modules/.nobody-node/node-v24.11.0:"$PATH" npm test', + }, + { + label: "the reply's full prefix without setpriv, in a checkout whose path has a space", + command: + '/usr/bin/env -u NODE_EXTRA_CA_CERTS HOME=/my\\ repo/node_modules/.nobody-home PATH=/my\\ repo/node_modules/.nobody-node/node-v24.11.0:"$PATH" npm test', + }, + { label: "env with -u at the end of a group of flags", command: "env -iu FOO npm test" }, + { label: "env with -C and the folder it enters", command: "env -C /tmp npm test" }, + { label: "env with --chdir and the folder it enters", command: "env --chdir /tmp npm test" }, + { + label: "env with -C and a double-quoted folder holding a space", + command: 'env -C "/my dir" npm test', + }, + { + label: "env with -C and a folder holding an escaped space", + command: "env -C /my\\ dir npm test", + }, + { + label: "env with -u and a name holding an escaped quote", + command: 'env -u "a\\"b" npm test', + }, + { label: "a double-quoted value holding an escaped quote", command: 'A="a\\"b" npm test' }, + { label: "npm by its path", command: "/usr/local/bin/npm test" }, + { label: "npx by its path", command: "/usr/local/bin/npx vitest run" }, + { + label: "node by its path running vitest.mjs", + command: "/usr/local/bin/node node_modules/vitest/vitest.mjs run", + }, + { label: "a += setting", command: "CI+=1 npm test" }, { label: "a double-quoted value with a space", command: 'FOO="a b" npm test' }, + { + label: "a value that mixes unquoted and quoted text", + command: 'PATH=/copy:"$PATH" npm test', + }, { label: "time", command: "time npm test" }, { label: "time with a flag", command: "time -p npm test" }, { @@ -448,7 +600,7 @@ describe("require-nonroot-tests hook", () => { { label: "a test run after a run through setpriv", command: - "setpriv --reuid=65534 --regid=65534 --clear-groups env HOME=/repo/node_modules/.nobody-home npm test; npm test", + "setpriv --reuid=65534 --regid=65534 --clear-groups /usr/bin/env HOME=/repo/node_modules/.nobody-home npm test; npm test", }, ])("stops $label and replies with the nobody prefix", ({ command }) => { const fixture = createHookFixture() @@ -471,6 +623,33 @@ describe("require-nonroot-tests hook", () => { { label: "npm run-script of another script", command: "npm run-script lint" }, { label: "npm exec of another binary", command: "npm exec prettier -- --version" }, { label: "bash -c with a quoted non-test command", command: "bash -c 'npm run lint'" }, + // The escaped space belongs to A's value, so the command is test, not npm. + { label: "a setting whose value ends in an escaped space", command: "A=a\\ npm test" }, + // In a group, a u or C takes the rest of the group as its argument, so + // FOO is the command env runs. + { label: "env with -u unsetting u, then FOO", command: "env -uu FOO npm test" }, + { label: "env with -u unsetting C, then FOO", command: "env -uC FOO npm test" }, + // Quoted text and heredoc bodies a command only passes as data. + { + label: "a commit message that mentions a run in parentheses", + command: + 'git commit -m "fix: x\n\n- the prefix (/usr/bin/env HOME=/h npm test) went\n through"', + }, + { + label: "a commit message from a cat heredoc that mentions a run", + command: `git commit -m "$(cat <<'EOF'\nfix: the (npm test) run "worked"\nEOF\n)"`, + }, + { + label: "a python heredoc whose body mentions a run", + command: "python3 - <<'EOF'\nprint(\"(`env -iu NAME npm test`)\")\nEOF", + }, + { + label: "a node -e script whose regex names a run", + command: "node -e 'if (!/^(npx|npm|npx vitest run src)/.test(text)) report()'", + }, + // A value ending in /npm is not npm by path: the shell runs test. + { label: "a setting whose value ends in /npm", command: "FOO=/x/npm test" }, + { label: "a setting whose value ends in /npx", command: "FOO=bin/npx -y vitest run" }, { label: "a quoted grep pattern that a | splits after a runner name", command: "grep -E 'npm test|vitest' src/", @@ -528,7 +707,7 @@ describe("require-nonroot-tests hook", () => { { label: "a command already run through setpriv", command: - "setpriv --reuid=65534 --regid=65534 --clear-groups env HOME=/repo/node_modules/.nobody-home npm test", + "setpriv --reuid=65534 --regid=65534 --clear-groups /usr/bin/env HOME=/repo/node_modules/.nobody-home npm test", }, ])("lets $label through without preparing folders", ({ command }) => { const fixture = createHookFixture() @@ -584,13 +763,14 @@ describe("require-nonroot-tests hook", () => { expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) }) - it("adds a line to the reply when nobody's node is another major version than .nvmrc", () => { + it("adds a line to the reply when neither nobody's node nor the session's is the .nvmrc major version", () => { const fixture = createHookFixture() const run = runHook({ fixture, stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), nobodyNodeVersion: "v22.22.0", + sessionNodeVersion: "v20.19.0", }) expect(run).toEqual({ @@ -600,10 +780,483 @@ describe("require-nonroot-tests hook", () => { }) }) + it("runs a copy of the hook's own node when it is the .nvmrc major version and nobody's is not", () => { + const fixture = createHookFixture() + const { uid, gid } = runnerIds() + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + }) + const copiedNode = readFileSync(join(nobodyNodeDir, "node"), "utf8") + + // The chown calls leave the copy out: it stays root's, so nobody can + // run it but not replace it. + expect({ + run, + copiedNode, + copyMode: permissionBits(join(nobodyNodeDir, "node")), + copyFolderEntries: readdirSync(nobodyNodeDir), + chownCalls: recordedChownCalls(fixture), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }, + copiedNode: SESSION_NODE_STUB, + copyMode: 0o755, + copyFolderEntries: ["node"], + chownCalls: [`-R ${uid}:${gid} ${scratchFolders(fixture.checkout).join(" ")}`], + }) + }) + + it("copies the binary the hook's own node runs from, not the version manager shim found on PATH", () => { + const fixture = createHookFixture() + const nodeBinary = join(fixture.outsideDir, "installs", "node") + const nodeBinaryContent = "#!/bin/sh\n# the node a shim starts\n" + mkdirSync(dirname(nodeBinary)) + writeExecutable(nodeBinary, nodeBinaryContent) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeBinary: nodeBinary, + }) + + expect({ + status: run.status, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ status: 2, copiedNode: nodeBinaryContent }) + }) + + it("makes the node copy and its folders readable and runnable by others under a restrictive umask", () => { + const fixture = createHookFixture() + const nodeCopiesDir = join(fixture.checkout, "node_modules", ".nobody-node") + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + umask: "077", + }) + + expect({ + status: run.status, + modes: [nodeCopiesDir, nobodyNodeDir, join(nobodyNodeDir, "node")].map(permissionBits), + }).toEqual({ status: 2, modes: [0o755, 0o755, 0o755] }) + }) + + it("reuses an earlier copy of the node instead of copying it again", () => { + const fixture = createHookFixture() + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + const earlierCopy = "#!/bin/sh\n# earlier copy\n" + mkdirSync(nobodyNodeDir, { recursive: true }) + writeExecutable(join(nobodyNodeDir, "node"), earlierCopy) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }, + copiedNode: earlierCopy, + }) + }) + + it("copies the node again when an earlier copy was cut short", () => { + const fixture = createHookFixture() + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + const cutShortBinDir = join(fixture.outsideDir, "cut-short-bin") + mkdirSync(cutShortBinDir) + writeExecutable(join(cutShortBinDir, "cp"), CUT_SHORT_CP_STUB) + const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) + + const cutShortRun = runHook({ + fixture, + stdin, + nobodyNodeVersion: OTHER_NODE_VERSION, + path: `${cutShortBinDir}:${fixture.stubBinDir}:${runnerPath()}`, + }) + const run = runHook({ fixture, stdin, nobodyNodeVersion: OTHER_NODE_VERSION }) + + // The cut-short run fails at its cp, whose partial file was already + // executable, and removes that file. The second run copies again. + expect({ + cutShortStatus: cutShortRun.status, + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + copyFolderEntries: readdirSync(nobodyNodeDir), + }).toEqual({ + cutShortStatus: 1, + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }, + copiedNode: SESSION_NODE_STUB, + copyFolderEntries: ["node"], + }) + }) + + it("copies nvm's newest node of the .nvmrc major version when the hook's own node is another version", () => { + const fixture = createHookFixture() + // v24.10.0 is newer than v24.9.0, though it sorts first as plain text. + writeNvmNodes(join(fixture.outsideDir, ".nvm"), ["v24.9.0", "v24.10.0", "v22.22.0"]) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.10.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.10.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.10.0\n", + }) + }) + + it("still copies the newest node it can read when find cannot read a folder under nvm's versions", () => { + const fixture = createHookFixture() + const nvmDir = join(fixture.outsideDir, ".nvm") + writeNvmNodes(nvmDir, ["v24.10.0"]) + // A folder find cannot enter makes it exit non-zero. + const unreadableFolder = join(nvmDir, "versions", "node", "v24.11.0") + mkdirSync(unreadableFolder) + chmodSync(unreadableFolder, 0o000) + onTestFinished(() => chmodSync(unreadableFolder, 0o755)) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.10.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.10.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.10.0\n", + }) + }) + + it("matches the .nvmrc major only in the version folder's name, not in the nvm folder's own path", () => { + const fixture = createHookFixture() + // The nvm folder's own path holds /v24.x/, and its only node is v18. + const nvmDir = join(fixture.outsideDir, "v24.x", "nvm") + writeNvmNodes(nvmDir, ["v18.20.0"]) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + nvmDir, + }) + + expect({ + run, + nodeCopiesMade: existsSync(join(fixture.checkout, "node_modules", ".nobody-node")), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout)}nobody's node is ${OTHER_NODE_VERSION}, not the 24 that .nvmrc names and CI runs, so a result can differ from CI's.\n`, + }, + nodeCopiesMade: false, + }) + }) + + it("copies the node in $NVM_DIR before a newer one in ~/.nvm", () => { + const fixture = createHookFixture() + const nvmDir = join(fixture.outsideDir, "nvm-dir") + writeNvmNodes(nvmDir, ["v24.1.0"]) + writeNvmNodes(join(fixture.outsideDir, ".nvm"), ["v24.10.0"]) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.1.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + nvmDir, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.1.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.1.0\n", + }) + }) + + it("copies the node in $NVM_DIR when HOME is unset", () => { + const fixture = createHookFixture() + const nvmDir = join(fixture.outsideDir, "nvm-dir") + writeNvmNodes(nvmDir, ["v24.1.0"]) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.1.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + nvmDir, + hasHome: false, + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.1.0" })}`, + }) + }) + + it("copies the node in ~/.nvm when $NVM_DIR has none of the .nvmrc major version", () => { + const fixture = createHookFixture() + const nvmDir = join(fixture.outsideDir, "nvm-dir") + writeNvmNodes(nvmDir, ["v22.22.0"]) + writeNvmNodes(join(fixture.outsideDir, ".nvm"), ["v24.10.0"]) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.10.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + nvmDir, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.10.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.10.0\n", + }) + }) + + it("copies the node in ~/.nvm, adding nothing to the reply, when $NVM_DIR's versions/node folder cannot be entered", () => { + const fixture = createHookFixture() + const nvmDir = join(fixture.outsideDir, "nvm-dir") + writeNvmNodes(nvmDir, ["v24.1.0"]) + // Readable but not searchable: the -d test passes, and cd fails. + const lockedVersionsDir = join(nvmDir, "versions", "node") + chmodSync(lockedVersionsDir, 0o600) + onTestFinished(() => chmodSync(lockedVersionsDir, 0o755)) + writeNvmNodes(join(fixture.outsideDir, ".nvm"), ["v24.10.0"]) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.10.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + nvmDir, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.10.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.10.0\n", + }) + }) + + it("copies the node in ~/.nvm when its versions/node folder is a symlink", () => { + const fixture = createHookFixture() + const movedNvmDir = join(fixture.outsideDir, "moved-nvm") + const nvmVersionsDir = join(fixture.outsideDir, ".nvm", "versions") + writeNvmNodes(movedNvmDir, ["v24.10.0"]) + mkdirSync(nvmVersionsDir, { recursive: true }) + symlinkSync(join(movedNvmDir, "versions", "node"), join(nvmVersionsDir, "node")) + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, "v24.10.0") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + sessionNodeVersion: OTHER_NODE_VERSION, + }) + + expect({ + run, + copiedNode: readFileSync(join(nobodyNodeDir, "node"), "utf8"), + }).toEqual({ + run: { + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: "v24.10.0" })}`, + }, + copiedNode: "#!/bin/sh\n# node v24.10.0\n", + }) + }) + + it("copies a node of the major version a full .nvmrc version names", () => { + const fixture = createHookFixture() + writeFileSync(join(fixture.checkout, ".nvmrc"), "v24.1.0\n") + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }) + }) + + it("copies no node when nobody's node has the major version a full .nvmrc version names", () => { + const fixture = createHookFixture() + writeFileSync(join(fixture.checkout, ".nvmrc"), "v24.1.0\n") + + const run = runCommandThroughHook(fixture, "npm test") + const nodeCopyFolderMade = existsSync(join(fixture.checkout, "node_modules", ".nobody-node")) + + expect({ run, nodeCopyFolderMade }).toEqual({ + run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, + nodeCopyFolderMade: false, + }) + }) + + it("quotes the node copy's folder in the prefix when the checkout's path holds a space", () => { + const fixture = createHookFixture() + const spacedCheckout = createOtherCheckout(fixture, "spaced checkout") + writeFileSync(join(spacedCheckout, ".nvmrc"), `${NVMRC_VERSION}\n`) + const escapedCheckout = spacedCheckout.replaceAll(" ", "\\ ") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: spacedCheckout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: `${expectedRefusal(spacedCheckout, { + nobodyHomeInPrefix: `${escapedCheckout}/node_modules/.nobody-home`, + nobodyNodeDir: nodeCopyFolder(escapedCheckout, MATCHING_NODE_VERSION), + })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }) + }) + + it("copies no node when nobody's node is already the .nvmrc major version", () => { + const fixture = createHookFixture() + + const run = runCommandThroughHook(fixture, "npm test") + const nodeCopyFolderMade = existsSync(join(fixture.checkout, "node_modules", ".nobody-node")) + + expect({ run, nodeCopyFolderMade }).toEqual({ + run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, + nodeCopyFolderMade: false, + }) + }) + + it("drops NODE_EXTRA_CA_CERTS from the prefix when nobody cannot read its file", () => { + const fixture = createHookFixture() + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + extraCaCerts: join(fixture.outsideDir, "missing-ca-bundle.crt"), + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: expectedRefusal(fixture.checkout, { unsetsExtraCaCerts: true }), + }) + }) + + // The reply a cloud container gets: env's -u must come before the + // settings, or env would take -u as the command to run. + it("drops NODE_EXTRA_CA_CERTS ahead of the settings when the prefix also runs a node copy", () => { + const fixture = createHookFixture() + const nobodyNodeDir = nodeCopyFolder(fixture.checkout, MATCHING_NODE_VERSION) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: OTHER_NODE_VERSION, + extraCaCerts: join(fixture.outsideDir, "missing-ca-bundle.crt"), + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout, { unsetsExtraCaCerts: true, nobodyNodeDir })}${expectedNodeCopyLine({ nobodyNodeVersion: OTHER_NODE_VERSION, copiedNodeVersion: MATCHING_NODE_VERSION })}`, + }) + }) + + it("keeps NODE_EXTRA_CA_CERTS when nobody can read its file", () => { + const fixture = createHookFixture() + const caBundle = join(fixture.outsideDir, "ca-bundle.crt") + writeFileSync(caBundle, "certificate\n") + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + extraCaCerts: caBundle, + }) + + expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) + }) + it("lets a test run through, preparing nothing, when nobody finds no node", () => { const fixture = createHookFixture() const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) + // The hook's own PATH still has a node, which a search by setpriv + // itself, with root's capabilities, would find. const run = runHook({ fixture, stdin, nobodyHasNode: false }) const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) const controlRun = runHook({ fixture, stdin }) @@ -641,6 +1294,25 @@ describe("require-nonroot-tests hook", () => { fixture, stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), nobodyNodeVersion: "v22.22.0", + sessionNodeVersion: "v20.19.0", + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: `${expectedRefusal(fixture.checkout)}nobody's node is v22.22.0, not the 24 that .nvmrc names and CI runs, so a result can differ from CI's.\n`, + }) + }) + + it("compares the major versions when .nvmrc starts with a space", () => { + const fixture = createHookFixture() + writeFileSync(join(fixture.checkout, ".nvmrc"), ` ${NVMRC_VERSION}\n`) + + const run = runHook({ + fixture, + stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), + nobodyNodeVersion: "v22.22.0", + sessionNodeVersion: "v20.19.0", }) expect(run).toEqual({ @@ -860,6 +1532,38 @@ describe("require-nonroot-tests hook", () => { expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) }) + it.each([ + { + label: "an absolute path", + command: (spacedCheckout: string) => + `cd ${spacedCheckout.replaceAll(" ", "\\ ")} && npm test`, + }, + { + label: "a relative path from an earlier cd", + command: (spacedCheckout: string) => { + return `cd ${dirname(spacedCheckout)} && cd ${basename(spacedCheckout).replaceAll(" ", "\\ ")} && npm test` + }, + }, + ])("prepares the checkout a cd names with an escaped space in $label", ({ command }) => { + const fixture = createHookFixture() + const spacedCheckout = createOtherCheckout(fixture, "spaced checkout") + + // The session's folder is in no checkout, so only the cd's path can + // reach one. + const run = runHook({ + fixture, + stdin: payloadFor({ command: command(spacedCheckout), cwd: fixture.outsideDir }), + }) + + expect(run).toEqual({ + status: 2, + stdout: "", + stderr: expectedRefusal(spacedCheckout, { + nobodyHomeInPrefix: `${spacedCheckout.replaceAll(" ", "\\ ")}/node_modules/.nobody-home`, + }), + }) + }) + it.each([ { label: "a ~ path", target: "~" }, { label: "a variable", target: "$HOME" }, @@ -891,6 +1595,39 @@ describe("require-nonroot-tests hook", () => { }, ) + it("prepares the checkout a cd names with an escaped leading /", () => { + const fixture = createHookFixture() + const otherCheckout = createOtherCheckout(fixture) + + // The session's folder is in no checkout, so only the cd's path can + // reach one. + const run = runHook({ + fixture, + stdin: payloadFor({ + command: `cd \\${otherCheckout} && npm test`, + cwd: fixture.outsideDir, + }), + }) + + expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }) + }) + + it("prepares the session's checkout for a cd to an escaped leading -, which cd reads as a flag", () => { + const fixture = createHookFixture() + const otherCheckout = createOtherCheckout(fixture) + // The folder the path names once the shell drops the backslash: a hook + // that followed the cd would land in the other checkout. + mkdirSync(join(otherCheckout, "-src")) + + const run = runCommandThroughHook(fixture, `cd ${otherCheckout} && cd \\-src && npm test`) + const controlRun = runCommandThroughHook(fixture, `cd ${otherCheckout} && npm test`) + + expect({ run, controlRun }).toEqual({ + run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, + controlRun: { status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }, + }) + }) + it("prepares the session's checkout for a cd followed only by a comment, which bash runs as a bare cd", () => { const fixture = createHookFixture() const otherCheckout = createOtherCheckout(fixture) @@ -936,6 +1673,21 @@ describe("require-nonroot-tests hook", () => { }) }) + it("prepares the session's checkout, not env's -C folder", () => { + const fixture = createHookFixture() + const otherCheckout = createOtherCheckout(fixture) + + const run = runCommandThroughHook(fixture, `env -C ${otherCheckout} npm test`) + // A cd to the other checkout moves the run there, so the fixture can + // tell the two checkouts apart. + const controlRun = runCommandThroughHook(fixture, `cd ${otherCheckout} && npm test`) + + expect({ run, controlRun }).toEqual({ + run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, + controlRun: { status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }, + }) + }) + it("stops a test run thousands of commands after the cd that names its checkout", () => { const fixture = createHookFixture() const otherCommands = Array.from({ length: 5000 }, (_, lineIndex) => `echo line ${lineIndex}`)