fix(claude_code): pin daily compat run to the 3-day-buffered claude-code version - #33476
fix(claude_code): pin daily compat run to the 3-day-buffered claude-code version#33476mateo-berri wants to merge 3 commits into
Conversation
…ode version PR #32548 deleted the CircleCI claude_code_compat_pr_gate job, the only automated consumer of pr_gate_version_resolver.py, so the 3-day npm publish-age security buffer (PRD #26476) stopped gating any automated path; run_daily.sh just probed whatever claude was on the cron VM's PATH. The daily runner now resolves the buffered version with the resolver, npm-installs exactly that version into a per-run prefix under WORKDIR (both steps under the script's env -i credential scrub with the isolated per-run HOME), prepends the install's bin dir to PATH, and turns the existing version probe into a verification that dies on a mismatch with the resolved version.
Greptile SummaryThis PR wires
Confidence Score: 5/5Safe to merge; the changes are self-contained to the cron VM scripts and their publisher unit tests, with no impact on litellm's core request path or SDK. All four changed files are in the cron/publisher test tree. The resolver, install, and probe logic in run_daily.sh is straightforward and each security-relevant property (env scrub, namespace isolation, PATH scoping, version verification) is pinned by a dedicated test that would have failed against the pre-fix code. No logic errors or missing cases were found. No files require special attention.
|
| Filename | Overview |
|---|---|
| tests/e2e/claude_code/cron_vm/run_daily.sh | Adds version resolution (pr_gate_version_resolver.py), pinned npm install, and version-verification probe; restricts CLAUDE_CLI_BIN to exactly the two env -i blocks that spawn claude; adds unshare preflight and sandbox wrapping for both package-code execution points. |
| tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py | New test file: six tests covering the exact-version npm install, env-scrub on resolver and npm, die-on-version-mismatch, structural pins on env -i wrapping, CLAUDE_CLI_BIN scoping, and unshare wrapping of both package-code execution points. |
| tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py | Updated extraction anchors from CLAUDE_CODE_VERSION= to PROBED_CLAUDE_VERSION= to track the probe variable rename; adds _anchor helper with pytest.fail diagnostics to replace bare index() calls. |
| tests/e2e/claude_code/pr_gate_version_resolver.py | Docstring-only update: corrects consumer reference from CircleCI PR gate to run_daily.sh; logic and CLI surface unchanged. |
Reviews (3): Last reviewed commit: "fix(claude_code): run npm package code i..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…s that spawn claude The npm install's node_modules/.bin was prepended to PATH at script scope, so every later step (git, gh with the agent token, curl, uv sync, the docs publish) ran with package-controlled binaries first on PATH; a compromised dependency shipping a bin named git or gh would have executed with the full systemd-injected token environment. The bin dir is now captured once as CLAUDE_CLI_BIN and prepended only inside the two env -i allowlists that need claude: the version probe and the pytest invocation (cli_driver re-derives its allowlist from the pytest env). A structural test pins the exactly-two prepends and rejects any script-scope PATH reassignment. Anchor lookups in the touched test files now fail via pytest.fail naming the missing anchor instead of raising a bare ValueError.
| log "resolved claude code: ${CLAUDE_CODE_VERSION}" | ||
|
|
||
| CLAUDE_CLI_PREFIX="${WORKDIR}/claude-cli" | ||
| env -i \ |
There was a problem hiding this comment.
High: Parent-process credentials remain accessible
A malicious package lifecycle script can walk to the ancestor run_daily.sh process and read /proc/<pid>/environ, which still contains the provider keys and GitHub token; the installed claude binary has the same access when it runs. Run package-controlled code in a separate sandbox/user that cannot inspect the secret-bearing process, or perform installation and verification before loading credentials rather than relying on env -i alone.
There was a problem hiding this comment.
Addressed in eebf941. The two points that execute package code, the npm install with its lifecycle scripts and the installed claude binary during the version probe, now run inside an unprivileged user+pid namespace (unshare --user --map-current-user --pid --fork --mount-proc) within the existing env -i allowlists, so package code gets a freshly mounted /proc in which the secret-bearing parent process does not exist and its environ cannot be read. A preflight check dies with a clear diagnostic on kernels that restrict unprivileged user namespaces instead of silently degrading, and structural tests pin the wrapping. Validated on a Linux kernel as an unprivileged uid; the transcript is in the PR description
The remaining same-uid exposure while the pytest CLI cells run is pre-existing to this PR and belongs to a follow-up, uid separation via a split systemd unit
PR overviewThis pull request updates the Claude Code end-to-end daily compatibility cron script to pin the run to a Claude Code version buffered by three days rather than using the latest release immediately. One security issue remains open: package-controlled code invoked by the daily run can still access credentials left in the ancestor shell process environment, including provider keys and a GitHub token. If a malicious package lifecycle script or installed Claude binary runs in that context, it could read and exfiltrate those secrets. No issues have been addressed yet, so the PR still carries a significant credential-exposure risk. Open issues (1)
Fixed/addressed: 0 · PR risk: 8/10 |
…mespace env -i scrubs the environment but is not a boundary: a malicious npm lifecycle script or the installed claude binary runs as the same uid and can read the secret-bearing parent's /proc/<pid>/environ directly. The two package-code execution points (npm install and the claude version probe) now run under unshare --user --map-current-user --pid --fork --mount-proc inside the existing env -i allowlists, giving package code a fresh /proc in which the parent does not exist. The resolver stays outside the namespace since it is our own stdlib code. unshare joins the required-commands loop and a preflight namespace check dies with a clear diagnostic on kernels that restrict unprivileged user namespaces instead of silently degrading. The extracted-block tests execute with a fake unshare stub and a new structural test pins the wrapping and the preflight requirement.
Relevant issues
Resolves caveat 6 of #32548 ("Caveats I could not fix in this move"): dropping the per-PR CircleCI compat gate left
pr_gate_version_resolver.py's 72-hour publish-age selection (PRD #26476) with no automated consumer, sorun_daily.shprobed whateverclaudehappened to be on the cron VM's PATH and the security-review buffer gated nothingLinear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
run_daily.shtargets the Linux cron VM (systemd unit, GCP), so a full script run is not possible on this macOS host. The proof below exercises the new resolve, pin, and verify steps end to end with the exact commands the script now runs, against the real npm registryBefore, captured at c6d49a8 (the base commit): the script never resolves or installs anything, and the probe picks up whatever
claudeis on PATH; on this host that is 2.1.211, published hours ago and squarely inside the 3-day window the buffer is supposed to enforceAfter, captured at 8220c57. Step 1, the resolver selects the newest version published at least 3 days ago
Step 2, registry timestamps confirm 2.1.207 (published 2026-07-10, 5 days old) is the correct pick while four newer versions exist but are all under 3 days old
Step 3, installing exactly that version into a throwaway prefix and probing it, as the script does per run
(The install printed an EBADENGINE warning because this host runs node v20 while the package wants >=22; it is a non-fatal warning and the cron VM's node is not affected by this PR)
The namespace isolation was validated at eebf941 on a real Linux kernel (6.8, Ubuntu, local colima VM) in a node:22 container as unprivileged uid 1000, with the secrets injected into the parent bash's environment the same way the systemd unit injects them. First the vulnerability, then the fix, then the exact production incantation
One validation gap: the container host needed
kernel.apparmor_restrict_unprivileged_userns=0for the run above (Ubuntu's default of 1 makes the uid_map write fail with EPERM, which the script's preflight turns into a clear die). Before enabling the timer, smoke-test on the actual VM as the runtime user withunshare --user --map-current-user --pid --fork --mount-proc bash -c 'grep -sal . /proc/*/environ || echo isolated'; if the preflight die fires instead, apply the sysctl or AppArmor exception described under ChangesType
🐛 Bug Fix
Changes
run_daily.shnow resolves the target Claude Code version by runningpr_gate_version_resolver.pyfrom the dev checkout, npm-installs exactly@anthropic-ai/claude-code@<resolved>into a per-run prefix under${WORKDIR}(removed by the existing cleanup trap), and exposes that install'snode_modules/.bin(captured once asCLAUDE_CLI_BIN) only inside the twoenv -iblocks that spawnclaude: the version probe and the pytest invocation, whosecli_driver.pyre-derives its own allowlist from the pytest env. No other step (git, gh with the agent token, curl, jq, uv sync, the docs publish) sees package-controlled binaries on PATH, and PATH is never reassigned at script scope. The existingclaude --versionprobe became a verification: the run dies if the probed semver differs from the resolved versionBoth the resolver and the npm install run under the script's existing
env -iminimal allowlist with the isolated per-run HOME. npm postinstall executes package code, which is precisely the supply-chain vector the 3-day buffer exists for, so it must never see the systemd-injected provider and agent tokensThe env scrub alone is not a boundary though: package code still runs as the same uid and could read the secrets straight out of the script's own /proc/pid/environ. The two points that execute package code (the npm install with its lifecycle scripts, and the installed
claudebinary during the probe) therefore also run insideunshare --user --map-current-user --pid --fork --mount-proc, an unprivileged user+pid namespace with a freshly mounted /proc in which the secret-bearing parent process does not exist. The resolver stays outside the namespace since it is our own stdlib-only code.unsharejoined the required-commands loop and a preflight check dies with a clear diagnostic on kernels that restrict unprivileged user namespaces, so the script fails closed instead of silently degrading to unsandboxed package execution. Note for deployment: Ubuntu 23.10+ shipskernel.apparmor_restrict_unprivileged_userns=1by default, which makes the preflight die; the VM needs that sysctl set to 0 or an AppArmor profile grantingusernsto the service (Debian has no such restriction). The remaining same-uid exposure while the pytest CLI cells run is pre-existing to this PR and belongs to a follow-up (uid separation via a split systemd unit)Caveat 6 of #32548 asked for the resolver to be wired in "behind a checksum-pinned install". A static in-repo checksum cannot work here because the resolved version changes daily; the equivalent control is that an exact-version
npm install @anthropic-ai/claude-code@X.Y.Zis integrity-verified by npm against the sha512 the registry packument declares for that version, so the installed artifact is pinned to the resolved, 3-day-aged releaseThe required-command loop drops
claude(the pinned install now provides it) and addsnpmandpython3, with the header comment updated to match. Comments that referenced the deleted CircleCI PR gate as if it still existed were corrected in the files this PR touches: the pytest env-scrub rationale inrun_daily.shand the module docstring inpr_gate_version_resolver.py, which now names the daily runner as the consumerTests:
_publisher_unit_tests/test_run_daily_pins_resolver_version.pyextracts the resolve/install/probe block out ofrun_daily.shand executes it with a stub resolver and a fakenpm(mirroring the fake-curl technique intest_run_daily_release_pagination.py), covering the exact-version install into the per-run prefix, the env scrub plus isolated HOME on both the resolver and npm steps, and the die-on-mismatch verification; structural tests pin theenv -iwrapping, the user+pid namespace wrapping of both package-code execution points (the extracted block executes with a fakeunsharestub that drops the flags and execs the rest), the required-command swap, and that the npm bin dir reaches PATH in exactly the two claude-spawningenv -iblocks with no script-scope PATH reassignment. Each fails against the code it guards pre-fix. Anchor lookups into the script fail viapytest.failnaming the missing line instead of raising a bare ValueError.test_run_daily_version_probe_scrubs_env.py's extraction markers track the probe's rename toPROBED_CLAUDE_VERSIONQA runbook
Prerequisites: network access to registry.npmjs.org; no proxy or provider credentials needed, these are publisher script tests. Note the two pre-existing
test_run_daily_release_pagination.pyexecution tests fail on macOS because/bin/bash3.2 rejects empty-array expansion underset -u; they pass on Linux CI and are untouched by this PRtests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_run_daily_installs_exactly_the_resolver_selected_version - the resolve/pin block installs exactly the resolver-selected version into
${WORKDIR}/claude-clipython3 tests/e2e/claude_code/pr_gate_version_resolver.pyand note the version printed to stdoutWORKDIR=$(mktemp -d)then the section ofrun_daily.shbetweenCLAUDE_PROBE_HOME=andlog "pinned claude code:withPOPULATOR_DIR=tests/e2e/claude_code/cron_vm${WORKDIR}/claude-cli/node_modules/.bin/claude --versionto report exactly the resolver's versiontests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_resolver_and_npm_install_run_under_env_scrub - neither the resolver nor npm sees the systemd-injected credentials or the real HOME
export ANTHROPIC_API_KEY=canary AGENT_SHIN_GITHUB_TOKEN=canarybefore running the same sectionnpmfirst on PATH that dumpsenvto a file (this is what the test automates), then run the sectionHOMEto equal${WORKDIR}/claude-probe-hometests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_probe_dies_when_installed_version_mismatches_resolved - the probe is now a verification that kills the run on drift
npmwrite aclaudethat echoes9.9.9 (Claude Code)regardless of the requested versionclaude on PATH reports 9.9.9, expected pinned <resolved>tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_static_resolver_and_install_are_env_i_wrapped - structural pin on the
env -iwrapping and the required-command swapgrep -n 'env -i' tests/e2e/claude_code/cron_vm/run_daily.shand confirm the resolver and npm-install invocations sit insideenv -iblocksgrep -n 'for cmd in' tests/e2e/claude_code/cron_vm/run_daily.shshowsgit uv gh jq curl npm python3with noclaudetests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_static_npm_bin_dir_scoped_to_probe_and_pytest_env_blocks - the pinned install's bin dir is visible only to the two
env -iblocks that spawnclaudegrep -c 'PATH="${CLAUDE_CLI_BIN}:${PATH}"' tests/e2e/claude_code/cron_vm/run_daily.shand expect 2, one inside the probe block and one inside the pytest blockgrep -n '^PATH=\|^export PATH=' tests/e2e/claude_code/cron_vm/run_daily.shand expect no output, so git, gh, curl, uv, and the docs publish never run with npm-controlled binaries first on PATHtests/e2e/claude_code/_publisher_unit_tests/test_run_daily_pins_resolver_version.py::test_static_npm_install_and_probe_run_in_user_pid_namespace - both package-code execution points are unshare-wrapped and the script requires unshare up front
grep -c 'unshare --user --map-current-user --pid --fork --mount-proc' tests/e2e/claude_code/cron_vm/run_daily.shand expect 3 (preflight, npm install, probe)unshareand the preflightunshare ... true || diesits right below ittests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py (updated) - the probe-scrub pins still hold against the renamed
PROBED_CLAUDE_VERSIONblockpytest tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py -qand expect all 3 tests to passPROBED_CLAUDE_VERSION=so it still covers the actualclaude --versioninvocationFinal Attestation