Skip to content

fix(claude_code): pin daily compat run to the 3-day-buffered claude-code version - #33476

Open
mateo-berri wants to merge 3 commits into
litellm_internal_stagingfrom
litellm_run_daily_3day_pin
Open

fix(claude_code): pin daily compat run to the 3-day-buffered claude-code version#33476
mateo-berri wants to merge 3 commits into
litellm_internal_stagingfrom
litellm_run_daily_3day_pin

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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, so run_daily.sh probed whatever claude happened to be on the cron VM's PATH and the security-review buffer gated nothing

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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.sh targets 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 registry

Before, captured at c6d49a8 (the base commit): the script never resolves or installs anything, and the probe picks up whatever claude is 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 enforce

$ git show c6d49a85b2:tests/e2e/claude_code/cron_vm/run_daily.sh | grep -cE "pr_gate_version_resolver|npm install"
0

$ claude --version        # what the pre-fix probe reports as CLAUDE_CODE_VERSION
2.1.211 (Claude Code)

After, captured at 8220c57. Step 1, the resolver selects the newest version published at least 3 days ago

$ python3 tests/e2e/claude_code/pr_gate_version_resolver.py
pr_gate_version_resolver: selected @anthropic-ai/claude-code@2.1.207
2.1.207

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

$ npm view @anthropic-ai/claude-code time --json \
    | jq 'to_entries | map(select(.key | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))) | sort_by(.value) | .[-6:] | from_entries'
{
  "2.1.206": "2026-07-09T17:54:03.009Z",
  "2.1.207": "2026-07-10T22:25:09.430Z",
  "2.1.208": "2026-07-13T21:31:27.921Z",
  "2.1.209": "2026-07-14T04:45:37.792Z",
  "2.1.210": "2026-07-14T19:39:21.730Z",
  "2.1.211": "2026-07-15T19:24:07.013Z"
}

$ date -u +"%Y-%m-%dT%H:%M:%SZ"
2026-07-15T23:28:50Z

Step 3, installing exactly that version into a throwaway prefix and probing it, as the script does per run

$ npm install --prefix "$SCRATCH" @anthropic-ai/claude-code@2.1.207
added 2 packages in 2s

$ "$SCRATCH/node_modules/.bin/claude" --version
2.1.207 (Claude Code)

(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

== 1. env -i alone is not a boundary: same-uid child still reads the secret-bearing parent's environ
$ env -i PATH="$PATH" bash -c 'grep -sal super-secret-token /proc/*/environ || true'
/proc/14/environ

== 2. inside the user+pid namespace the parent does not exist and the secret is unreachable
$ env -i PATH="$PATH" unshare --user --map-current-user --pid --fork --mount-proc \
    bash -c 'echo "pids visible in fresh /proc: $(ls /proc | grep -cE "^[0-9]+$")"; grep -sal super-secret-token /proc/*/environ || echo "secret unreachable"'
pids visible in fresh /proc: 4
secret unreachable

== 3. exact production incantation: npm install then probe, both env -i + unshare
$ env -i PATH="$PATH" HOME="$CLAUDE_PROBE_HOME" USER=node TERM=dumb LANG=C.UTF-8 LC_ALL= TMPDIR=/tmp \
    unshare --user --map-current-user --pid --fork --mount-proc \
    npm install --prefix "$CLAUDE_CLI_PREFIX" @anthropic-ai/claude-code@2.1.207
added 2 packages in 4s
$ env -i PATH="$CLAUDE_CLI_BIN:$PATH" HOME="$CLAUDE_PROBE_HOME" USER=node TERM=dumb LANG=C.UTF-8 LC_ALL= TMPDIR=/tmp \
    unshare --user --map-current-user --pid --fork --mount-proc \
    claude --version
2.1.207 (Claude Code)
install written as uid 1000 (node), --map-current-user kept it writable

One validation gap: the container host needed kernel.apparmor_restrict_unprivileged_userns=0 for 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 with unshare --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 Changes

Type

🐛 Bug Fix

Changes

run_daily.sh now resolves the target Claude Code version by running pr_gate_version_resolver.py from 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's node_modules/.bin (captured once as CLAUDE_CLI_BIN) only inside the two env -i blocks that spawn claude: the version probe and the pytest invocation, whose cli_driver.py re-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 existing claude --version probe became a verification: the run dies if the probed semver differs from the resolved version

Both the resolver and the npm install run under the script's existing env -i minimal 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 tokens

The 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 claude binary during the probe) therefore also run inside unshare --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. unshare joined 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+ ships kernel.apparmor_restrict_unprivileged_userns=1 by default, which makes the preflight die; the VM needs that sysctl set to 0 or an AppArmor profile granting userns to 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.Z is 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 release

The required-command loop drops claude (the pinned install now provides it) and adds npm and python3, 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 in run_daily.sh and the module docstring in pr_gate_version_resolver.py, which now names the daily runner as the consumer

Tests: _publisher_unit_tests/test_run_daily_pins_resolver_version.py extracts the resolve/install/probe block out of run_daily.sh and executes it with a stub resolver and a fake npm (mirroring the fake-curl technique in test_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 the env -i wrapping, the user+pid namespace wrapping of both package-code execution points (the extracted block executes with a fake unshare stub 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-spawning env -i blocks with no script-scope PATH reassignment. Each fails against the code it guards pre-fix. Anchor lookups into the script fail via pytest.fail naming 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 to PROBED_CLAUDE_VERSION

QA 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.py execution tests fail on macOS because /bin/bash 3.2 rejects empty-array expansion under set -u; they pass on Linux CI and are untouched by this PR

  • tests/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-cli

    • Run python3 tests/e2e/claude_code/pr_gate_version_resolver.py and note the version printed to stdout
    • Run WORKDIR=$(mktemp -d) then the section of run_daily.sh between CLAUDE_PROBE_HOME= and log "pinned claude code: with POPULATOR_DIR=tests/e2e/claude_code/cron_vm
    • Expect ${WORKDIR}/claude-cli/node_modules/.bin/claude --version to report exactly the resolver's version
    • Sanity check: this test makes sense to add and is not hand-wavey (it asserts the exact npm package spec, prefix path, and resolved version) or potentially flaky
  • tests/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=canary before running the same section
    • Put a stub npm first on PATH that dumps env to a file (this is what the test automates), then run the section
    • Expect the dump to contain neither canary value and HOME to equal ${WORKDIR}/claude-probe-home
    • Sanity check: this test makes sense to add and is not hand-wavey or potentially flaky
  • tests/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

    • Make the stub npm write a claude that echoes 9.9.9 (Claude Code) regardless of the requested version
    • Re-run the section; expect exit 1 and stderr containing claude on PATH reports 9.9.9, expected pinned <resolved>
    • Sanity check: this test makes sense to add and is not hand-wavey or potentially flaky
  • 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 -i wrapping and the required-command swap

    • grep -n 'env -i' tests/e2e/claude_code/cron_vm/run_daily.sh and confirm the resolver and npm-install invocations sit inside env -i blocks
    • grep -n 'for cmd in' tests/e2e/claude_code/cron_vm/run_daily.sh shows git uv gh jq curl npm python3 with no claude
    • Sanity check: this test makes sense to add and is not hand-wavey or potentially flaky
  • tests/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 -i blocks that spawn claude

    • Run grep -c 'PATH="${CLAUDE_CLI_BIN}:${PATH}"' tests/e2e/claude_code/cron_vm/run_daily.sh and expect 2, one inside the probe block and one inside the pytest block
    • Run grep -n '^PATH=\|^export PATH=' tests/e2e/claude_code/cron_vm/run_daily.sh and expect no output, so git, gh, curl, uv, and the docs publish never run with npm-controlled binaries first on PATH
    • Sanity check: this test makes sense to add and is not hand-wavey or potentially flaky
  • tests/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

    • Run grep -c 'unshare --user --map-current-user --pid --fork --mount-proc' tests/e2e/claude_code/cron_vm/run_daily.sh and expect 3 (preflight, npm install, probe)
    • Confirm the required-commands loop lists unshare and the preflight unshare ... true || die sits right below it
    • On a Linux box, repeat the two-command demonstration from the proof section: without unshare the parent environ is greppable, inside it the secret is unreachable
    • Sanity check: this test makes sense to add and is not hand-wavey or potentially flaky
  • tests/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_VERSION block

    • Run pytest tests/e2e/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py -q and expect all 3 tests to pass
    • Confirm the extracted block in the test now starts at PROBED_CLAUDE_VERSION= so it still covers the actual claude --version invocation
    • Sanity check: this test makes sense to keep and is not hand-wavey or potentially flaky

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…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-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires pr_gate_version_resolver.py into run_daily.sh so the daily cron job resolves the newest @anthropic-ai/claude-code version published at least 3 days ago, npm-installs exactly that version into a per-run prefix, and verifies the installed binary reports the expected version before proceeding — replacing the previous behaviour of probing whatever claude happened to be on PATH.

  • run_daily.sh gains resolver invocation (under env -i + isolated HOME), a unshare --user --pid wrapped npm install, a version-verification probe that dies on mismatch, and strict scoping of CLAUDE_CLI_BIN to only the two env -i blocks that spawn claude (probe and pytest). A preflight check kills the run on kernels that block unprivileged user namespaces, preventing silent fallback to unsandboxed package execution.
  • Six new structural and execution tests in test_run_daily_pins_resolver_version.py pin the exact-version install, env-scrub on resolver and npm, die-on-mismatch behaviour, PATH scoping, and unshare wrapping; test_run_daily_version_probe_scrubs_env.py is updated to track the PROBED_CLAUDE_VERSION rename.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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

Comment thread tests/e2e/claude_code/cron_vm/run_daily.sh Outdated
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_run_daily_3day_pin (eebf941) with litellm_internal_staging (69a491e)

Open in CodSpeed

…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.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

log "resolved claude code: ${CLAUDE_CODE_VERSION}"

CLAUDE_CLI_PREFIX="${WORKDIR}/claude-cli"
env -i \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@veria-ai

veria-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PR overview

This 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.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant