Root cause
Injection & unsafe execution introduced
directive: per-session quota-status files (#104) (#105)
How AI contributed: Direct introductionPublished May 13, 2026 in
cnighswonger/claude-code-cache-fix
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 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. 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
tools/quota-statusline.sh piped Claude Code's hook stdin into a Python triple-quoted literal through json.loads('''$input'''), so a payload containing a closing quote sequence ended the literal early and the remaining bytes ran as Python in the user's process. The fix passes the payload through the CC_INPUT environment variable and a single-quoted heredoc.
Root cause
directive: per-session quota-status files (#104) (#105)
Fix
fix: shell injection in tools/quota-statusline.sh (security #108, v3.5.2) (#110)
AI-assisted fix: Claude Code + ChatGPT/Codex · vsits-proxy-builder[bot]
Case facts
@@ -234,6 +234,7 @@ export function normalizeToolResultContent(messages, match, canonicalText) { function hashSessionId(reqCtx) { const sid = reqCtx?.meta?.session_id ||+ reqCtx?.headers?.["x-claude-code-session-id"] || reqCtx?.headers?.["x-session-id"] || reqCtx?.headers?.["x-anthropic-session-id"] || null;@@ -1,8 +1,63 @@-import { writeFileSync, mkdirSync } from "node:fs";+import {+ writeFileSync,+ renameSync,+ unlinkSync,+ mkdirSync,+ readdirSync,+ statSync,+} from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os";+import { createHash, randomBytes } from "node:crypto"; -const QUOTA_PATH = join(homedir(), ".claude", "quota-status.json");+// Paths are resolved per call (not cached at module load) so tests can swap+// $HOME between cases. The homedir() call is essentially free.+function paths() {+ const home = homedir();+ const quotaDir = join(home, ".claude", "quota-status");+ return {+ quotaDir,+ accountPath: join(quotaDir, "account.json"),+ sessionsDir: join(quotaDir, "sessions"),+ legacyPath: join(home, ".claude", "quota-status.json"),+ };+}++const SAFE_NAME_RE = /^[A-Za-z0-9_-]{1,128}$/;+const SWEEP_THROTTLE_MS = 60_000;+const DEFAULT_TTL_DAYS = 7;++// --- Module-scope state ---+let legacyCleanupDone = false;+let lastSweepMs = 0;++// Per directive `proxy-quota-status-per-session.md` — derive a filesystem-safe+// filename from a raw session id. Both writer (this extension) and readers+// (tools/quota-statusline.sh, etc.) must apply the same rule.+//+// Rules:+// - null/undefined/empty/whitespace → "unknown"+// - matches /^[A-Za-z0-9_-]{1,128}$/ → raw passthrough+// - else → "inv-" + sha256(raw)[:16]+//+// Exported for unit testing and for the directive's writer/reader contract.+export function sessionFilename(rawId) {+ if (rawId === null || rawId === undefined) return "unknown";+ const s = String(rawId).trim();+ if (s.length === 0) return "unknown";+ if (SAFE_NAME_RE.test(s)) return s;+ return "inv-" + createHash("sha256").update(s).digest("hex").slice(0, 16);+}++function resolveSessionId(headers) {+ if (!headers) return null;+ const sid =+ headers["x-claude-code-session-id"] ||+ headers["x-session-id"] ||+ headers["x-anthropic-session-id"] ||+ null;+ return sid || null;+} function parseHeaders(headers) { const get = (key) => headers[key] || "";@@ -44,9 +99,56 @@ function parseHeaders(headers) { }; } +function atomicWrite(finalPath, content) {+ const tmp = `${finalPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;+ writeFileSync(tmp, content);+ renameSync(tmp, finalPath);+}++function cleanupLegacyOnce() {+ if (legacyCleanupDone) return;+ legacyCleanupDone = true;+ try {+ unlinkSync(paths().legacyPath);+ } catch {}+}++function sweepStaleSessions(ttlDays) {+ const now = Date.now();+ if (now - lastSweepMs < SWEEP_THROTTLE_MS) return;+ lastSweepMs = now;++ const cutoffMs = now - ttlDays * 86_400_000;@@ -0,0 +1,163 @@+// Smoke tests for tools/quota-statusline.sh — T1-T5 from the per-session+// quota-status directive. Runs the shell script as a subprocess under a+// tmpdir-rooted HOME so we don't touch the developer's real ~/.claude/.++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 { tmpdir } from "node:os";+import { join, resolve } from "node:path";+import { fileURLToPath } from "node:url";+import { createHash } from "node:crypto";++const SCRIPT = resolve(fileURLToPath(import.meta.url), "..", "..", "tools", "quota-statusline.sh");++function setupHome() {+ const home = mkdtempSync(join(tmpdir(), "qsl-"));+ mkdirSync(join(home, ".claude", "quota-status", "sessions"), { recursive: true });+ return {+ home,+ account: join(home, ".claude", "quota-status", "account.json"),+ sessionsDir: join(home, ".claude", "quota-status", "sessions"),+ cleanup: () => rmSync(home, { recursive: true, force: true }),+ };+}++const ACCOUNT_JSON = JSON.stringify({+ five_hour: { utilization: 0.42, pct: 42, resets_at: 1776960600 },+ seven_day: { utilization: 0.15, pct: 15, resets_at: 1776970800 },+ status: "allowed",+ overage_status: "allowed",+ peak_hour: false,+ timestamp: "2026-05-05T12:00:00.000Z",+});++const SESSION_JSON = JSON.stringify({+ cache: {+ ttl_tier: "1h",+ cache_creation: 0,+ cache_read: 12345,+ ephemeral_1h: 0,+ ephemeral_5m: 0,+ hit_rate: "92.5",+ timestamp: "2026-05-05T12:00:00.000Z",+ },+ timestamp: "2026-05-05T12:00:00.000Z",+ session_id: "b16c607d-d484-4935-840e-e3f7ee78eb08",+});++function runScript(home, stdin) {+ return spawnSync("bash", [SCRIPT], {+ input: stdin,+ env: { ...process.env, HOME: home },+ encoding: "utf8",+ });+}++test("T1. UUID session, both files present → label has quota and TTL", () => {+ const env = setupHome();+ try {+ writeFileSync(env.account, ACCOUNT_JSON);+ writeFileSync(join(env.sessionsDir, "b16c607d-d484-4935-840e-e3f7ee78eb08.json"), SESSION_JSON);+ const r = runScript(env.home, '{"session_id": "b16c607d-d484-4935-840e-e3f7ee78eb08"}');+ assert.equal(r.status, 0);+ assert.match(r.stdout, /Q5h: 42%/);+ assert.match(r.stdout, /Q7d: 15%/);+ assert.match(r.stdout, /TTL:1h/);+ assert.match(r.stdout, /92\.5%/);+ } finally {+ env.cleanup();+ }+});++test("T2. session_id missing from stdin → quota shown, no TTL block (sessions/unknown.json absent)", () => {+ const env = setupHome();+ try {+ writeFileSync(env.account, ACCOUNT_JSON);+ // No sessions/unknown.json on disk → reader applies rule, attempts read,+ // file missing, falls back to account-only.+ const r = runScript(env.home, "{}");+ assert.equal(r.status, 0);+ assert.match(r.stdout, /Q5h: 42%/);+ assert.match(r.stdout, /Q7d: 15%/);+ assert.doesNotMatch(r.stdout, /TTL:/);+ } finally {+ env.cleanup();+ }+});+@@ -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"@@ -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.@@ -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 ### FixedAI-assisted change 3808d33d987e091a87772869d237a8ce33b57519a64dc5311ef4163eca47b23a · Fix df0fd8bd6f013b9d52964ed7cc2e21dd4a0b5d1d0e8fce52f47f170c2560e965