VYPR
High severityGHSA Advisory· Published May 13, 2026· Updated May 13, 2026

claude-code-cache-fix vulnerable to local code execution via Python triple-quote injection in tools/quota-statusline.sh

CVE-2026-45136

Description

Summary

tools/quota-statusline.sh (introduced in v3.5.0) interpolates Claude Code's hook stdin payload directly into a Python triple-quoted string literal. A ''' byte sequence in any user-controlled field of the payload closes the literal early and lets following bytes execute as Python in the user's Claude Code process.

Affected versions

  • v3.5.0
  • v3.5.1

Patched versions

  • v3.5.2

Affected configurations

Users who wired tools/quota-statusline.sh into Claude Code's statusLine configuration. The v3.5.0 README explicitly recommends this setup, so most users on v3.5.0/v3.5.1 with the recommended setup are affected.

Attack chain

Claude Code's statusline hook payload reflects user-controlled paths (cwd, workspace.current_dir, workspace.project_dir, transcript_path). Apostrophes are legal in POSIX filesystem paths.

  1. A hostile directory name containing '''+payload+''' lands on disk via any normal vector — git clone, archive extraction, npm package, downloaded zip, etc.
  2. The victim has the recommended tools/quota-statusline.sh wired into their CC statusLine config.
  3. The victim cds anywhere a hostile path is reachable.
  4. CC fires the statusline hook on every redraw. The Python literal closes early. The injected bytes execute as Python in the user's process.

Severity

Local code execution at user privilege. Persistent re-fire on every statusline redraw. No user interaction beyond cd-ing into the hostile path. The user's shell, CC session, files, SSH keys, and any locally-accessible credentials are reachable from the executed code.

Vulnerable pattern

input=$(cat)
result=$(python3 -c "
    stdin_data = json.loads('''$input''') if '''$input''' else {}
")

Fix

Capture stdin in bash, export to env, and pipe the Python source through a single-quoted heredoc (<<'PYEOF'). Single-quoting disables ALL bash interpolation inside the body. Python reads the JSON via os.environ.get('CC_INPUT'), where the bytes are inert at every layer.

CC_INPUT=$(cat)
export CC_INPUT

python3 <<'PYEOF' 2>/dev/null
import os, json
try:
    cc_input = json.loads(os.environ.get('CC_INPUT') or '{}')
except Exception:
    cc_input = {}
# ...
PYEOF

Workarounds

Until upgrading to v3.5.2:

  • Disable the statusline by removing the statusLine entry from ~/.claude/settings.json, or
  • Replace tools/quota-statusline.sh with a script that does NOT pass stdin through python3 -c "..." (a heredoc + env var rewrite is safe)

Credit

Reported by Jakob Linke (@schuay) via GitHub issue #108.

Timeline

  • 2026-05-07 — reported (#108)
  • 2026-05-07 — confirmed, fix implemented (#110)
  • 2026-05-07 — v3.5.2 published

Affected products

1

Patches

1
613e4df30547

fix: shell injection in tools/quota-statusline.sh (security #108, v3.5.2) (#110)

https://github.com/cnighswonger/claude-code-cache-fixvsits-proxy-builder[bot]May 7, 2026via ghsa
5 files changed · +140 9
  • CHANGELOG.md+10 0 modified
    @@ -2,6 +2,16 @@
     
     ## [Unreleased]
     
    +## [3.5.2] - 2026-05-07
    +
    +### Security
    +
    +- **`tools/quota-statusline.sh`: shell injection via Python triple-quoted literal (#108).** The v3.5.0 statusline rewrite interpolated CC's hook stdin payload directly into a Python triple-quoted string (`json.loads('''$input''')`). A `'''` byte sequence anywhere in the payload closed the literal early and let the following bytes execute as Python in the user's CC process. Because CC's hook payload reflects user-controlled paths (`cwd`, `workspace.current_dir`, `workspace.project_dir`, `transcript_path`) and apostrophes are legal in filesystem paths, a hostile directory name on disk (planted via `git clone`, archive extraction, npm package, etc.) could trigger arbitrary local code execution at the user's privilege every time CC redrew the statusline. **Severity: local code execution, persistent re-fire on every statusline tick, no user interaction beyond `cd`-ing into the hostile path.** Fix: capture stdin in bash, `export CC_INPUT`, and pipe the Python source through a single-quoted heredoc (`<<'PYEOF'`) which disables ALL bash interpolation in the body. Python now reads the JSON via `os.environ.get('CC_INPUT')`, where the bytes are inert at every layer. Adds T6 + T7 regression tests that drive the exact `'''+__import__('os').system(...)+'''` pattern against the script under a tmpdir-rooted `HOME` and assert the sentinel file is never created. Reported by [@schuay (Jakob Linke)](https://github.com/schuay) in [#108](https://github.com/cnighswonger/claude-code-cache-fix/issues/108) — thank you for the responsible disclosure.
    +
    +### Tests
    +
    +735 → 737 (+2): T6 and T7 regression coverage for the #108 injection vector — payload in `session_id` and in non-`session_id` user-controlled fields (`cwd`, `workspace.current_dir`, `transcript_path`).
    +
     ## [3.5.1] - 2026-05-05
     
     ### Fixed
    
  • docs/code-reviews/pr-110-security-hotfix-review-2026-05-07.md+31 0 added
    @@ -0,0 +1,31 @@
    +# Review: PR #110 security hotfix
    +
    +Date: 2026-05-07
    +Reviewed: tools/quota-statusline.sh, test/quota-statusline-smoke.test.mjs, CHANGELOG.md, package.json
    +Label applied: reviewed-by-codex-agent
    +
    +## What Is Correct
    +
    +- The Python invocation in `tools/quota-statusline.sh` is now fed by a single-quoted heredoc (`<<'PYEOF'`), which prevents shell interpolation inside the embedded Python source.
    +- `CC_INPUT` is captured and exported before the Python process starts, and the Python code reads hook payload bytes only through `os.environ.get('CC_INPUT')`.
    +- I found no remaining shell interpolation points in the statusline Python invocation chain that would let stdin content influence shell parsing or Python source construction.
    +- The canonical filename rule still blocks directory traversal by hashing non-allowlisted values to `inv-<sha256[:16]>`; direct verification for `../../../etc/passwd` resolves to `inv-56bfa7338a2dfd1d`.
    +- Regression tests T6 and T7 invoke the production script, place sentinels under a tmpdir-rooted `HOME`, use a representative `'''+__import__('os').system(...)+'''` payload, and would fail if the vulnerable `python3 -c "...$input..."` pattern were restored.
    +- The changelog entry correctly states the attack chain, severity, reporter credit, release version `3.5.2`, and date `2026-05-07`.
    +
    +## Blockers
    +
    +None
    +
    +## What Needs Attention
    +
    +- Residual repo-wide risk: other helper scripts still use `python3 -c` with shell-substituted arguments. I did not find the same stdin-to-source construction in this hotfix path, but those utilities should be audited separately so this class of bug does not reappear elsewhere.
    +
    +## Recommendations
    +
    +- Ship this hotfix.
    +- In a follow-up hardening pass, document a project rule to avoid `python3 -c` with shell interpolation for untrusted data and prefer env vars or stdin-fed heredocs.
    +
    +## Bottom Line
    +
    +This hotfix closes the reported local code execution vector in `tools/quota-statusline.sh` without regressing the session filename safety contract. The implementation mechanics are correct, the new tests would catch a reintroduction of the vulnerable pattern, and I found no blocking issues for release.
    
  • package.json+1 1 modified
    @@ -1,6 +1,6 @@
     {
       "name": "claude-code-cache-fix",
    -  "version": "3.5.1",
    +  "version": "3.5.2",
       "description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
       "type": "module",
       "exports": "./preload.mjs",
    
  • test/quota-statusline-smoke.test.mjs+72 1 modified
    @@ -5,7 +5,7 @@
     import { test } from "node:test";
     import assert from "node:assert/strict";
     import { spawnSync } from "node:child_process";
    -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
    +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
     import { tmpdir } from "node:os";
     import { join, resolve } from "node:path";
     import { fileURLToPath } from "node:url";
    @@ -161,3 +161,74 @@ test("T5. malformed session_id → reads hashed filename matching the writer's r
         env.cleanup();
       }
     });
    +
    +test("T6 (security #108). triple-quote injection payload does NOT execute — heredoc isolation intact", () => {
    +  // Regression for cnighswonger/claude-code-cache-fix#108: pre-v3.5.2,
    +  // tools/quota-statusline.sh interpolated stdin into a Python triple-quoted
    +  // literal (`json.loads('''$input''')`). A `'''` byte sequence in any
    +  // user-controlled field — session_id, cwd, workspace.current_dir, etc. —
    +  // closes the literal early and lets the following bytes execute as Python
    +  // in the user's CC process. The fix moves stdin into a single-quoted
    +  // bash heredoc + env var so the bytes are inert at every layer.
    +  //
    +  // This test pipes a payload that, on the vulnerable script, creates a
    +  // sentinel file via __import__('os').system. After the script runs, the
    +  // sentinel must NOT exist.
    +  const env = setupHome();
    +  // Sentinel must live under the test's tmpdir-rooted HOME so we never
    +  // touch real /tmp state and the assertion can't false-pass against a
    +  // pre-existing file the developer happens to have.
    +  const sentinel = join(env.home, "PWNED_SHOULD_NOT_EXIST");
    +  try {
    +    writeFileSync(env.account, ACCOUNT_JSON);
    +    // Build the malicious session_id. Triple-single-quote closes the literal,
    +    // then arbitrary Python runs, then we re-open with `+'''` so the original
    +    // syntax of the vulnerable line stays balanced (otherwise json.loads
    +    // raises before reaching the dangerous code, masking the test).
    +    const malicious = `abc'''+__import__('os').system(${JSON.stringify(`touch ${sentinel}`)})+'''def`;
    +    const stdinPayload = JSON.stringify({ session_id: malicious });
    +    const r = runScript(env.home, stdinPayload);
    +    assert.equal(r.status, 0, `script must exit clean even with hostile payload; stderr=${r.stderr}`);
    +    // The script should still render a normal quota line — the hostile
    +    // payload is just a weird session_id string from the parser's perspective.
    +    assert.match(r.stdout, /Q5h: 42%/);
    +    // The critical assertion: no execution.
    +    assert.equal(
    +      existsSync(sentinel),
    +      false,
    +      `SECURITY REGRESSION: triple-quote injection payload created ${sentinel} — heredoc isolation has broken. See cache-fix issue #108.`,
    +    );
    +  } finally {
    +    env.cleanup();
    +  }
    +});
    +
    +test("T7 (security #108). injection in non-session_id fields is also inert", () => {
    +  // The brief flagged that CC's hook payload has multiple user-controlled
    +  // string fields (cwd, workspace.current_dir, workspace.project_dir,
    +  // transcript_path). Even though the current script only consumes
    +  // session_id from the parsed JSON, a future change might surface other
    +  // fields. Belt-and-suspenders: confirm the heredoc isolation holds when
    +  // the malicious bytes appear elsewhere in the JSON object.
    +  const env = setupHome();
    +  const sentinel = join(env.home, "PWNED_VIA_CWD_SHOULD_NOT_EXIST");
    +  try {
    +    writeFileSync(env.account, ACCOUNT_JSON);
    +    const malicious = `/tmp/foo'''+__import__('os').system(${JSON.stringify(`touch ${sentinel}`)})+'''bar`;
    +    const stdinPayload = JSON.stringify({
    +      cwd: malicious,
    +      workspace: { current_dir: malicious, project_dir: malicious },
    +      transcript_path: malicious,
    +      session_id: "valid-session-id-passes-canonical-rule",
    +    });
    +    const r = runScript(env.home, stdinPayload);
    +    assert.equal(r.status, 0);
    +    assert.equal(
    +      existsSync(sentinel),
    +      false,
    +      `SECURITY REGRESSION via non-session_id field: ${sentinel} created. See cache-fix issue #108.`,
    +    );
    +  } finally {
    +    env.cleanup();
    +  }
    +});
    
  • tools/quota-statusline.sh+26 7 modified
    @@ -9,19 +9,37 @@
     # CC pipes hook input as JSON on stdin including `session_id`, which we map to
     # the per-session filename via the canonical rule (matches the writer in
     # proxy/extensions/cache-telemetry.mjs:sessionFilename).
    -
    -input=$(cat)
    +#
    +# Security (v3.5.2, #108): the previous version interpolated stdin into a
    +# Python triple-quoted literal via "''$input''", which lets a `'''` byte
    +# sequence in the payload close the literal early and execute arbitrary
    +# Python. CC's hook payload reflects user-controlled paths (cwd,
    +# workspace.current_dir, transcript_path), and apostrophes are legal in
    +# filenames, so a hostile directory name on disk could trigger code
    +# execution on every CC statusline redraw. We capture stdin in bash, export
    +# it to the env, and pass the python source through a single-quoted heredoc
    +# (`<<'PYEOF'`) which disables ALL bash interpolation in the body. Python
    +# reads the JSON via os.environ.get('CC_INPUT'), where the bytes are inert.
    +
    +# Capture stdin in bash before the python heredoc consumes the stdin slot,
    +# then export so the python child sees it.
    +CC_INPUT=$(cat)
    +export CC_INPUT
     
     ACCOUNT="$HOME/.claude/quota-status/account.json"
    -SESSIONS_DIR="$HOME/.claude/quota-status/sessions"
     
     # Show quota even if no per-session file exists yet (fresh session, first
     # request hasn't fired). Per-session block just gets blank.
     if [ ! -f "$ACCOUNT" ]; then
       exit 0
     fi
     
    -result=$(python3 -c "
    +# IMPORTANT: the heredoc tag is single-quoted (`<<'PYEOF'`). This disables
    +# all bash interpolation inside the heredoc body. Do NOT change to `<<PYEOF`
    +# without a matching audit — that would re-introduce the injection vector
    +# the v3.5.2 hotfix closed. The python source must reference CC_INPUT only
    +# through os.environ, never via a shell-substituted string.
    +result=$(python3 <<'PYEOF' 2>/dev/null
     import sys, json, os, re, hashlib
     from datetime import datetime, timezone, timedelta
     
    @@ -34,14 +52,14 @@ sessions_dir = os.path.join(home, '.claude', 'quota-status', 'sessions')
     # canonical rule decides — the writer maps all those to 'unknown',
     # the reader must do the same to keep the contract identical.
     try:
    -    stdin_data = json.loads('''$input''') if '''$input''' else {}
    +    stdin_data = json.loads(os.environ.get('CC_INPUT') or '{}')
     except Exception:
         stdin_data = {}
     sess_id_raw = stdin_data.get('session_id')
     
     # Canonical filename derivation — must match cache-telemetry.mjs:sessionFilename.
     # Allowlist: [A-Za-z0-9_-]{1,128}; else inv-<sha256(s)[:16]>; null/empty/whitespace -> 'unknown'.
    -SAFE = re.compile(r'^[A-Za-z0-9_-]{1,128}\$')
    +SAFE = re.compile(r'^[A-Za-z0-9_-]{1,128}$')
     def session_filename(raw):
         if raw is None:
             return 'unknown'
    @@ -121,6 +139,7 @@ if peak:
         label += ' | \033[33mPEAK\033[0m'
     
     print(label)
    -" 2>/dev/null)
    +PYEOF
    +)
     
     [ -n "$result" ] && echo "$result"
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

5

News mentions

0

No linked articles in our index yet.