Repository navigation
chore(hooks): give nobody test runs the .nvmrc node and drop an unreadable CA bundle - #669
aliasunder wants to merge 19 commits into
Conversation
…ble CA bundle In Claude Code's cloud containers the setpriv prefix ran the container's own node, another major version than .nvmrc, and inherited a NODE_EXTRA_CA_CERTS file nobody cannot read. The first fails every test that needs a newer node API; the second adds a warning to every child process's stderr. - When nobody's node is another major version but the session's matches .nvmrc, the hook copies the session's node into nobody's HOME and puts it first on PATH in the prefix. - When nobody cannot read the NODE_EXTRA_CA_CERTS file, the prefix unsets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkA1FHrosoEim4HpZyQGyQ
… node is another version Claude Code starts hooks with its own PATH, so in a cloud container the hook's node is the container's, not the session's nvm node. The hook now takes the newest node of the .nvmrc major version from $NVM_DIR or ~/.nvm instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkA1FHrosoEim4HpZyQGyQ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkA1FHrosoEim4HpZyQGyQ
…hole and root-owned - setpriv keeps root's capabilities until it starts its command, so `setpriv … node --version` reached nvm's node under /root and reported the .nvmrc version even when nobody's runs (env, then npx's env shebang) get another. The probe now goes through /usr/bin/env, as the prefix does. - cp makes its file executable before writing it, so a copy cut short by the hook's timeout, or one a concurrent hook was still writing, passed the -x reuse check for good. The copy now lands under a temporary name and mv renames it into place. - The copy moves from nobody's HOME to node_modules/.nobody-node, a root-owned folder (755), so root no longer writes where nobody could plant a symlink, and nobody can run the copy but not replace it. Ship-Check: pr-review · claude-opus-5-5
…which PATH each lookup uses - Rename ci_node_* to nvmrc_node_* and hook_node_*, set the copied version where its path is set, and name the copies' parent folder. - Compute node_major_differs once and use it for both the copy and the version warning. - Build env's arguments as an array, so the reply's spacing needs no per-setting convention. - Keep the writable-folder setup in one block before the version check. - Reword the comments a stranger stopped on: the major-version match, the unset NODE_EXTRA_CA_CERTS, which PATH the probe and the prefix search, why a missing node lets the run through, the shebang lookup that makes npx and tsx use the copy, and sh's $0. Ship-Check: code-quality · claude-opus-5-5
…s edges - The setpriv stub now looks a bare command up on the hook's PATH, as real setpriv does with root's capabilities, and leaves a command named by path to search nobody's PATH. A probe that drops /usr/bin/env now fails the tests. - New cases: a copy cut short is copied again, $NVM_DIR wins over ~/.nvm and falls through to it, a full .nvmrc version compares by major, the copy and its folders stay 0755 under umask 077, the copy's folder is quoted in the prefix, and the copy is never handed to nobody. - The reuse test asserts the whole run. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Update the test header's stub list for the new node stub
The header still says the hook runs 'with real node' and lists only Failure scenario: A contributor adds a hook-behavior test assuming the hook's Suggested fixReword the header to list the node stub and state that only `--version` is stubbed, with other calls handed to the real node.umm-actually · z-ai/glm-5.3-flash |
|
umm-actually re-reviewed at 3 new finding(s) posted (16 tracked finding(s) across all runs). umm-actually · deepseek/deepseek-v4.1-flash, z-ai/glm-5.3-flash |
…de runs from - The prefix printed `PATH=<copy>:$PATH` with $PATH unquoted. bash splits it when a folder on the session's PATH holds a space (WSL appends Windows folders such as "Program Files"), and env then takes the text after the space as its command. The prefix now prints `:"$PATH"`. - When the hook's own node matched .nvmrc, the hook copied `command -v node`, which can be a version manager's shim (asdf, mise, volta). The copy then fails as nobody, and the -x reuse check keeps the broken copy. The hook now copies `node -p process.execPath`, the binary node runs from. - A failed cp (a full disk) left its partial temporary file behind, holding the space it took. The hook now removes it before ending as set -e would. - find -L enters a symlinked versions/node or version folder under nvm's folders. - AGENTS.md: the prefix runs a copy of root's Node of the .nvmrc major only if root has one. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/usr/bin/env in the prefix - find exits non-zero on a folder it cannot read, and under set -e and pipefail that ended the hook with status 1, which lets the run through as root. The search now keeps the nodes it could list. - The printed prefix names /usr/bin/env in full, for the same reason the probe does: setpriv searches PATH with root's capabilities. - The test file's header lists the node stub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to umm-actually's PR-level finding "Update the test header's stub list for the new node stub" (comment 6113553059): fixed in 203b1df. The header of 🔍 ship-check · pr-monitor · claude-opus-5-5 Generated by Claude Code |
…etpriv cases through /usr/bin/env - Root reads a folder whatever its mode, so the hook, which runs only as root, rarely meets one find cannot read. A symlink loop that -L walks into also makes find exit non-zero while it still lists the other nodes, so the comment on || true names both. - The two test commands that stand for a run through the reply's prefix now use /usr/bin/env, as the reply does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Ship-Check: bug-check · claude-opus-5-5
…ath, and anchor the nvm search - The reply's prefix now names /usr/bin/env, so a run that kept only the env part of that prefix went through as root: the wrapper patterns matched env, time, timeout and the shells only by their bare names. Each may now be named by path. - The nvm search matched a node of another major version when the nvm folder's own path held a folder such as v24.x. find now runs from inside versions/node, so -path sees only nvm's own folders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…et the nvm search's cd - In a cloud container the reply's prefix carries env's -u NODE_EXTRA_CA_CERTS and a PATH setting of the form /copy:"$PATH". The wrapper read -u's name as the command env runs, and a variable setting took only one quoting form for its whole value, so that prefix used without setpriv still went through as root. env's -u and --unset now take their name, and a value may mix unquoted and quoted parts. - The nvm search's cd printed its error into the reply when $NVM_DIR's versions/node passed the -d test but could not be entered, where find had dropped it. cd's error now goes to /dev/null, and CDPATH is cleared so a relative $NVM_DIR enters the folder the -d test checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Ship-Check: bug-check · claude-opus-5-5
…-C and grouped -u - In a checkout whose path holds a space, the reply's prefix writes the space escaped (`HOME=/my\ repo/...`). The unquoted part of a setting stopped at that space, so the prefix without setpriv ran as root. A backslash now escapes the next character. - env's -C (--chdir) takes a folder, and -u also takes a name at the end of a group of flags (`env -iu NAME`). Both read their argument as the command, so the run after them went through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkA1FHrosoEim4HpZyQGyQ
…copy No test set both parts of the prefix at once, though a cloud container's reply has both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkA1FHrosoEim4HpZyQGyQ
|
Resolve cd targets with escaped spaces to the real directory
Pre-existing: an unquoted cd target is read only up to the first space, so the backslash-escaped spelling ( Failure scenario: Root session with cwd outside any checkout (e.g. /root) and the command Suggested fixExtend the two cd path atoms with the same escaped-character alternative the variable-setting grammar uses (e.g. (/([^[:space:]'\"\\\\]|\\\\.)*) and the relative equivalent) and strip the escaping backslashes before use (current_dir=${BASH_REMATCH[2]//\\/}) so git -C receives the real path; add a stop-case for `cd /spaced\\ repo && npm test` whose cwd is outside any checkout.umm-actually · z-ai/glm-5.3-flash |
An unquoted cd path stopped at its first space, escaped or not, so `cd /my\ repo && npm test` named /my\ and, from a folder in no checkout, let the run through as root. The cd patterns now take a backslash-escaped character, and the walk drops the backslash as the shell does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Only env's flags that take no argument (-0, -i, -v) may come before a -u or -C in one group. A u or C earlier in the group takes the rest of the group as its argument, so the next word is the command env runs, and the hook stopped such runs although they run no tests. - A -u or -C argument is now a shell word, the pattern a setting's value uses, so a quoted or escaped space in a -C folder no longer lets the run through. - The walk does not follow env's -C folder, as it does not follow npm's --prefix; the comment now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re: Resolve cd targets with escaped spaces to the real directory: fixed in 592cca1. I reproduced it:
Two new tests cover an absolute and a relative escaped path, each run from a folder in no checkout. They fail when the pattern is reverted, and also when only the unescape is removed. Generated by Claude Code |
… quotes - The walk unescaped each cd path in a subshell, with a loop that rescanned the path per escape, so a command with thousands of cds could pass the hook's 10-second limit and run as root. read without -r drops the backslashes as a builtin. - An escaped leading - in a relative cd path (`cd \-foo`) is a flag to cd, as the comment says, so the walk no longer follows it. - A double-quoted part may hold an escaped character, so an env -u or -C argument or a setting's value holding \" is read whole. - npm and npx named by path (/usr/local/bin/npm test) are runners too. - The ~/.nvm search reads HOME with a default, so an unset HOME no longer ends the hook under set -u. - A test pins that env's -C folder is not followed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The case created a folder named with the backslash, so a hook that followed the cd fell back to the session's checkout and passed anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…\/ and += forms - A folder holding = is a setting's value, so `FOO=/x/npm test`, which runs the test builtin, is no longer stopped as npm named by path. - node named by path running vitest.mjs is a runner, as npm, npx and vitest by path already are. - A VAR+=value setting before the command is read as one. - A cd path starting with \/ is absolute, not relative. - A .nvmrc that starts with a space still names its version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The split breaks a command at ;, &, |, ( and backticks even inside quotes, so a commit message, an inline script or a heredoc that only mentioned a test run produced a line starting with one, and the hook blocked a command that runs no tests. The node step that parses the payload now blanks those characters inside quotes and drops heredoc bodies and comments, unless a word of the command names bash, sh or zsh, whose quoted text and heredocs run. A $( ) inside double quotes is still read as commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Closing unmerged. The root-test hook decides whether a Bash command runs vitest by reading the command text with regular expressions, and every review round found more shell forms it misreads in both directions. Instead, a follow-up PR replaces the hook with a check inside vitest itself, which runs only when vitest actually starts as root. That PR removes the hook, so this one's fixes to it won't be needed. Generated by Claude Code |
| const QUOTE = "\u0027" | ||
| const SEPARATORS = ";&|(`\n" | ||
| const SHELL_NAME = /(^|\/)(bash|sh|zsh)$/ | ||
| const HEREDOC_OPERATOR = /^<<(-?)[ \t]*(?:"([^"]*)"|\u0027([^\u0027]*)\u0027|([^\s;&|()<>]+))/ |
There was a problem hiding this comment.
Treat <<< as a here-string, not a heredoc, in the command masker
Low severity · subtle bugs · high confidence
The new masker's HEREDOC_OPERATOR matches << without checking for a third <, so a here-string like grep x <<< 'nothing' is parsed as a heredoc whose delimiter is the quoted text; at the next newline the body reader then swallows every following line searching for the never-appearing delimiter. A test run on a later line is thereby dropped from the masker's output, changing the outcome from main's stop to a silent run as root. The same <<< blind spot also leaves a here-string fed to a shell (bash <<< 'npm test') unrecognized by the wrapper patterns.
Failure scenario: In a root cloud session the command grep -q x <<< 'nothing' followed by npm test on the next line: the masker consumes << 'nothing' as a heredoc operator, readHeredocBodies then drops the npm test line while searching for the delimiter 'nothing', the walk sees no run, and the hook exits 0 so vitest runs as root — on main the same command was stopped.
Suggested fix
Make HEREDOC_OPERATOR require that the `<<` is not part of `<<<` (e.g. check command[index+2] !== "<" before accepting the match, or add a negative check on the preceding character), and add a stop-case whose command is a here-string line followed by `npm test` on the next line.umm-actually · z-ai/glm-5.3-flash
| 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}" |
There was a problem hiding this comment.
Stop test runs through eval like the other command wrappers
Low severity · correctness · high confidence
Pre-existing: the wrapper list covers env, time, exec, command, timeout and the shells, but not eval, so an eval'd test run is never recognized and the hook exits 0. exec and command are handled as sibling builtins that run a command, so eval is the asymmetry in the same family.
Failure scenario: In a root cloud session the command eval 'npm test' (or eval npm test): eval is not a wrapper, so no command_prefix matches, test_run fails, the hook exits 0 with no reply, and vitest runs as root with the root-only permission-test failures the hook exists to prevent.
Suggested fix
Add `eval` to the wrapper alternation with no flags (eval takes none), i.e. `|eval` beside `exec|command`, and add a stop-case for `eval 'npm test'`.umm-actually · z-ai/glm-5.3-flash
| { label: "a bash heredoc", command: "bash <<'EOF'\nnpm test\nEOF" }, | ||
| { label: "a line after a cat heredoc", command: "cat <<EOF >notes\nhello\nEOF\nnpm test" }, |
There was a problem hiding this comment.
Add a test for the heredoc reader's tab-stripping branch
Low severity · tests · high confidence
The new stripsTabs branch of readHeredocBodies (for <<- heredocs) has no test: every heredoc case in the stop and pass tables uses << or <<'EOF'. If the branch regresses, a tab-indented terminator never matches its delimiter and the reader consumes the rest of the command into the blanked body, so any test run after the heredoc is silently let through with no failing test.
Failure scenario: A regression flips stripsTabs (e.g. tabsFlag === false for <<-): for cat <<-EOF newline notes newline «tab»EOF newline npm test, the delimiter line «tab»EOF never equals EOF, readHeredocBodies swallows every remaining line including npm test, the hook exits 0, and the suite runs as root — no existing test fails because none exercises <<-.
Suggested fix
Add a stop case `cat <<-EOF` newline `notes` newline tab-`EOF` newline `npm test` (expected: the exact refusal) and, if the tab-indented body of a bash heredoc matters, a matching pass/stop pair — the delimiter-line handling under <<- is currently unexercised by all 159 tests.umm-actually · z-ai/glm-5.3-flash
What does this PR do?
The root-test hook stops a test run in a root session and replies with a
setprivprefix that reruns it asnobody. In Claude Code's cloud containers, that prefix gave a run CI does not:nobody'snodeis the container's own (v22), not the major version.nvmrcnames and CI runs (24). nvm installs the right one under root's home, whichnobodycannot enter.server-error-contracts.test.tsrun alone on main: 26 failed withRegExp.escape is not a function.NODE_EXTRA_CA_CERTSto a file under root's home.nodethatnobodystarts prints "Ignoring extra certs … Permission denied" on stderr, so the two exact-stderr tests inrender-social-preview.test.tsfail.The prefix now matches CI in both respects:
nobody's node has another major version than.nvmrcnames, and root has a node of that major, the hook copies it tonode_modules/.nobody-node/node-<version>/nodeand puts that folder first onPATHin the prefix.node(the binary it runs from,process.execPath, so a version-manager shim is never copied). Otherwise it takes the newest node of that major under$NVM_DIR, then~/.nvm, matching the version only in nvm's own version folders. Claude Code starts hooks with its ownPATH, not the session's, so in a cloud container the hook'snodeis usually the container's.nobodycan run the copy but not replace it. The hook writes to a temp name and moves it into place, so a later run never reuses a partial copy, and it reuses a whole one.npx,npm execandtsxlooknodeup onPATH, and the integration harness starts servers withprocess.execPath, so all of them run the copy.nobody's node: it runs/usr/bin/env node.setprivkeeps root's capabilities until it starts its command, sosetpriv … nodewould find root's nvm node instead of the onenobody's run uses.NODE_EXTRA_CA_CERTSis set andnobodycannot read the file, the prefix adds-u NODE_EXTRA_CA_CERTS. The suites this prefix runs make no network calls. A filenobodycan read stays set./usr/bin/envin full for the same reason the check does. It also quotes"$PATH", so a session PATH with a space in a folder name stays one argument.setprivwould otherwise go through:env,time,timeout,bash,shorzshnamed by path, such as/usr/bin/env HOME=… npm test;-u NAME, also at the end of a group of flags (env -iu NAME npm test), and a setting that mixes quoted and unquoted text (PATH=/copy:"$PATH");HOME=/my\ repo/…);-C/--chdirand the folder it takes.A reply in a cloud container now reads:
A session that is not root still exits at the hook's first check, so nothing changes on a developer machine.
Failure paths.
Not covered. Runs through
sudo,nice,nohuporenv -S '…'still go through as root, as on main.Type of change
.claude/hooks/require-nonroot-tests.sh)Checklist
npm testpassesnpm run buildsucceeds (no source the build compiles changed).nvmrcmajor, if root has oneTests
require-nonroot-tests.test.tshas 159 tests, up from 130 on main.nodestub answers the version and binary-path questions the hook asks of its own node, and hands every other call (the payload parse) to the real node.setprivstub now modelssetpriv's own PATH search. A bare command name resolves on the hook's PATH; a command given by path searchesnobody's PATH.cpstub cuts a copy short.$NVM_DIRis searched before~/.nvm, and~/.nvmis used when$NVM_DIRhas none.versions/nodeis followed, and a folderfindcannot read is survived./v24.x/in the nvm folder's own path does not count as a match..nvmrc(v24.1.0) is honoured.nobody.NODE_EXTRA_CA_CERTSis dropped or kept as described above.A=a\ npm test, which runstest) goes through./usr/bin/envcheck, which fails 9 tests.Review
Ship-check phases 1–5 ran on this branch, plus delta reviews of every commit after them. Every finding was fixed on the branch:
nobody's node through/usr/bin/env;nobody's HOME into a root-owned folder."$PATH";process.execPathinstead of a possible shim;findfrom ending the hook;/usr/bin/envin the prefix;setpriv(env's-u, mixed quoting), and quieting the nvm search'scd;-Cand grouped-u.Live Test Results
Run in a Claude Code cloud container: a root session,
nobodyon node v22.22.0, nvm node v24.21.0 under/root/.nvm, andNODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt. Both full-suite rows ran in the same checkout at e8ca7dd; only the prefix differs.npx vitest run src/utilsas rootenv HOME=…/.nobody-home, plus "nobody's node is v22.22.0, not the 24 that .nvmrc names"server-error-contracts.test.tswith the hook's prefixRegExp.escape is not a function)render-social-preview.test.tswith a Node 24 prefix that keepsNODE_EXTRA_CA_CERTS/usr/bin/env HOME=… npm testas root (at 231b599)The bug check also exercised the hook live:
.nvmrcformsv24,24.1.0and CRLF24get a copy.lts/*and an empty file get none.No test deploy was run:
.dockerignoreexcludes.claudeand**/__tests__/, and the Dockerfile does not copyAGENTS.md, so the image built from this branch has the same contents as main's.🤖 Generated with Claude Code