From 5ad351bbd2bda554ff7a984bc281ab5ce947ec1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 19 May 2026 05:49:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(cron=5Fvm):=20veria=20=E2=80=94=20isolate?= =?UTF-8?q?=20$HOME=20and=20hide=20credential=20dotdirs=20from=20claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cron systemd unit's `ProtectHome=read-only` blocks writes to /home/mateo but still allows reads. With `HOME=/home/mateo` forwarded to the `claude` subprocess, a compromised @anthropic-ai/claude-code release (running during the `claude --version` probe) — or a model-directed `Read` tool call during a PDF cell (which passes `--allowed-tools Read`) — could read host credential files like ~/.config/gh/hosts.yml (gh-host token), ~/.ssh/, or ~/.bash_history and exfiltrate them. Two complementary mitigations, addressing veria's exact recommendation: 1. Per-invocation isolated HOME for every `claude` subprocess: * cli_driver.py: drop HOME from _CLI_ENV_ALLOWLIST; create a fresh empty tmpdir under tempfile.gettempdir() (`PrivateTmp=true` keeps it on a service-private tmpfs) and pass it as HOME to each `claude` invocation. Cleaned up in a `finally` so timeouts and CLI-not-found don't leak tmpdirs. * run_daily.sh: the up-front `claude --version` probe also runs under $CLAUDE_PROBE_HOME (a per-run dir under ${WORKDIR}) so the probe can never reach the runtime user's real home; the existing `cleanup` trap removes ${WORKDIR}. * Closes the `os.path.expanduser('~/.config/gh/hosts.yml')`-style attack from a compromised CLI / model. 2. Filesystem-level hiding of credential dotdirs in the systemd unit: * Add `InaccessiblePaths=-/home/mateo/.config/gh -/home/mateo/.ssh -/home/mateo/.aws -/home/mateo/.docker -/home/mateo/.kube -/home/mateo/.gnupg`. The kernel hides these paths from every process in the unit's mount namespace, defeating the absolute-path attack (`Read('/home/mateo/.config/gh/...')`) that the per- invocation HOME override alone cannot block. * Drop `/home/mateo/.config/gh` from `ReadWritePaths=` (it's now hidden, and we pass GH_TOKEN inline to every `gh` call). * Pass GH_TOKEN inline to `gh repo clone` in run_daily.sh (was relying on host gh-cli config); the docs repo is public so this is a no-op functionally, but it lets us drop the ~/.config/gh dependency entirely. Tests: * test_run_claude_uses_isolated_per_invocation_home: pin that the CLI subprocess never sees the parent's $HOME, and that the isolated HOME is a fresh tmpdir prefixed claude-cli-home-. * test_run_claude_isolated_home_is_distinct_per_invocation: pin that each call gets its own dir (no cross-call planting). * test_run_claude_isolated_home_cleaned_up_after_run / on_subprocess _failure: pin that the tmpdir is rm-rf'd on both the happy path and the timeout/CLI-error path. * test_version_probe_uses_isolated_home_not_runtime_user_home: pin that run_daily.sh's probe forwards $CLAUDE_PROBE_HOME, not ${HOME}, into its `env -i` block. * test_systemd_unit_credential_isolation.py (new): pin that InaccessiblePaths covers all credential dotdirs, that .config/gh is not under ReadWritePaths, and that ProtectHome stays at least read-only. All 349 existing claude_code unit tests still pass. Co-authored-by: Mateo Wang --- .../_driver_unit_tests/test_cli_driver.py | 133 +++++++++++++++++- ...test_run_daily_version_probe_scrubs_env.py | 33 ++++- .../test_systemd_unit_credential_isolation.py | 104 ++++++++++++++ tests/claude_code/cli_driver.py | 98 +++++++++---- .../cron_vm/litellm-compat-matrix.service | 64 +++++++-- tests/claude_code/cron_vm/run_daily.sh | 22 ++- 6 files changed, 413 insertions(+), 41 deletions(-) create mode 100644 tests/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py diff --git a/tests/claude_code/_driver_unit_tests/test_cli_driver.py b/tests/claude_code/_driver_unit_tests/test_cli_driver.py index c3cec2a79fd..5bf646e7f45 100644 --- a/tests/claude_code/_driver_unit_tests/test_cli_driver.py +++ b/tests/claude_code/_driver_unit_tests/test_cli_driver.py @@ -122,7 +122,7 @@ def test_run_claude_extra_env_is_added_to_subprocess_env(): def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch): - """Process-runtime vars (PATH, HOME) flow through; credentials don't. + """Process-runtime vars (PATH) flow through; credentials don't. The `claude` CLI is a Node binary installed dynamically from npm in CI. If the package were ever compromised, inheriting the entire @@ -131,6 +131,9 @@ def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch): Pin the contract: only the small allowlist of runtime vars is inherited; everything else is dropped unless the caller passes it explicitly via extra_env. + + `HOME` is *not* on the allowlist anymore — see the dedicated + isolated-HOME test below for the reason. """ monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") monkeypatch.setenv("HOME", "/home/runner") @@ -150,7 +153,6 @@ def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch): ) env = captured["env"] assert env["PATH"] == "/usr/bin:/usr/local/bin" - assert env["HOME"] == "/home/runner" assert "AWS_SECRET_ACCESS_KEY" not in env assert "ANTHROPIC_API_KEY" not in env assert "AZURE_FOUNDRY_API_KEY" not in env @@ -158,6 +160,133 @@ def test_run_claude_inherits_only_allowlisted_os_environ(monkeypatch): assert "GITHUB_TOKEN" not in env +def test_run_claude_uses_isolated_per_invocation_home(monkeypatch, tmp_path): + """`claude` subprocess never sees the runtime user's real $HOME. + + The CLI needs *a* HOME (it caches per-session state under + `$HOME/.claude/projects//`), but it has no business reading + the runtime user's real one. On the cron VM the runtime user is a + real interactive account with a populated home directory + (~/.config/gh/hosts.yml carrying a GitHub token, ~/.ssh/, etc.); + handing /home/mateo to a compromised npm package — or to a + model-directed `Read` tool call during the PDF/vision cells — + would let it exfiltrate those files. We hand the CLI a fresh + empty per-invocation tmpdir instead. + """ + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + env = captured["env"] + assert "HOME" in env, "claude CLI needs HOME to find ~/.claude session dir" + assert ( + env["HOME"] != "/home/runner" + ), "HOME must not leak the parent process's HOME to claude" + # The isolated HOME is a fresh tmpdir prefixed `claude-cli-home-`; + # see `_make_isolated_home` in cli_driver.py. It exists during the + # subprocess call and is removed afterwards (cleanup runs in a + # `finally`, so by the time this assertion runs the dir is gone — + # we only check the *prefix* of the path string we captured). + assert "claude-cli-home-" in env["HOME"] + + +def test_run_claude_isolated_home_is_distinct_per_invocation(monkeypatch): + """Two consecutive calls get two different isolated HOMEs. + + Reusing a single tmpdir across calls would defeat the isolation + in the parallel matrix run (a compromised CLI could plant a file + in HOME on one model's run and read it on the next). Pin: each + `run_claude` invocation gets its own freshly-created HOME. + """ + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + home_a = captured["env"]["HOME"] + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + home_b = captured["env"]["HOME"] + + assert home_a != home_b + + +def test_run_claude_isolated_home_cleaned_up_after_run(monkeypatch): + """The per-invocation HOME tmpdir is rm-rf'd when run_claude returns. + + Without cleanup, a long matrix run would accumulate one tmpdir + per cell × per model × per CLI call (~75 dirs per cron run, + growing without bound across days). + """ + import os as _os + + monkeypatch.setenv("HOME", "/home/runner") + + runner, captured = _make_runner(stdout="") + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + isolated_home = captured["env"]["HOME"] + assert not _os.path.exists( + isolated_home + ), f"isolated HOME {isolated_home!r} should be removed after run_claude returns" + + +def test_run_claude_isolated_home_cleaned_up_on_subprocess_failure(monkeypatch): + """Cleanup runs even when the CLI subprocess raises. + + If the CLI is missing or times out, `run_claude` raises + `ClaudeCLIError` — but the per-invocation HOME tmpdir must still + be removed (the `finally` clause), otherwise long failure-prone + runs leak tmpdirs. + """ + import os as _os + + monkeypatch.setenv("HOME", "/home/runner") + + captured: dict = {} + + def runner(cmd, env, capture_output, text, timeout, check, input=None): + captured["env"] = env + raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout) + + with pytest.raises(ClaudeCLIError): + run_claude( + prompt="hi", + model="claude-opus-4-7", + base_url="http://localhost", + api_key="sk-abc", + runner=runner, + ) + + isolated_home = captured["env"]["HOME"] + assert not _os.path.exists( + isolated_home + ), f"isolated HOME {isolated_home!r} should be removed even on timeout" + + def test_run_claude_extra_env_can_pass_through_otherwise_blocked_var(monkeypatch): """The allowlist applies to inherited os.environ; extra_env is the sanctioned way for a test to opt-in to passing something extra.""" diff --git a/tests/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py b/tests/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py index a77afd36096..c98de4507c0 100644 --- a/tests/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py +++ b/tests/claude_code/_publisher_unit_tests/test_run_daily_version_probe_scrubs_env.py @@ -24,7 +24,7 @@ RUN_DAILY = REPO_ROOT / "tests" / "claude_code" / "cron_vm" / "run_daily.sh" def _version_probe_block() -> str: body = RUN_DAILY.read_text() start = body.index("CLAUDE_CODE_VERSION=") - end = body.index("[[ -n \"${CLAUDE_CODE_VERSION}\" ]]", start) + end = body.index('[[ -n "${CLAUDE_CODE_VERSION}" ]]', start) return body[start:end] @@ -59,3 +59,34 @@ def test_version_probe_env_i_excludes_provider_secrets() -> None: f"not pass {forbidden} through. Found it inside the probe " f"block." ) + + +def test_version_probe_uses_isolated_home_not_runtime_user_home() -> None: + """Pin: the `claude --version` probe runs under a fresh empty HOME. + + `ProtectHome=read-only` in the systemd unit allows reads of the + runtime user's real home directory. If the probe's `env -i` + block forwards `HOME=${HOME}`, a compromised `claude` package + can `os.path.expanduser("~/.config/gh/hosts.yml")` or + `os.path.expanduser("~/.ssh/...")` and exfiltrate the contents + before the proxy or test harness ever starts. The probe must + point HOME at a per-run tmpdir under `${WORKDIR}` so the CLI + sees an empty HOME instead. + """ + block = _version_probe_block() + body = RUN_DAILY.read_text() + + assert "CLAUDE_PROBE_HOME=" in body, ( + "run_daily.sh: must define a `CLAUDE_PROBE_HOME` per-run tmpdir " + "for the `claude --version` probe so the CLI never sees the " + "runtime user's real $HOME." + ) + assert 'HOME="${CLAUDE_PROBE_HOME}"' in block, ( + "run_daily.sh: the probe's `env -i` block must set HOME to " + "the per-run isolated tmpdir, not to the runtime user's $HOME." + ) + assert 'HOME="${HOME}"' not in block, ( + "run_daily.sh: the probe's `env -i` block must not forward the " + "runtime user's $HOME to `claude --version`. Use the isolated " + "$CLAUDE_PROBE_HOME tmpdir instead." + ) diff --git a/tests/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py b/tests/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py new file mode 100644 index 00000000000..f928c1b46b7 --- /dev/null +++ b/tests/claude_code/_publisher_unit_tests/test_systemd_unit_credential_isolation.py @@ -0,0 +1,104 @@ +"""Pin: the cron systemd unit hides credential-bearing dotdirs. + +`ProtectHome=read-only` blocks writes to /home/mateo but still allows +reads. A model-directed `Read` tool call (the PDF cells pass +`--allowed-tools Read` to the `claude` CLI) or a compromised +`@anthropic-ai/claude-code` package can read absolute paths under +the runtime user's home and exfiltrate the contents — even with the +per-`claude`-invocation HOME isolation in place, because absolute +paths bypass `~`-expansion. + +This file pins the second line of defense: the systemd unit lists +the credential-bearing dotdirs (`~/.config/gh`, `~/.ssh`, `~/.aws`, +`~/.docker`, `~/.kube`, `~/.gnupg`) under `InaccessiblePaths=` so +the kernel hides them from every process in the unit's mount +namespace, including any child of `claude --version` or the pytest +run. It also pins that `~/.config/gh` is *not* in `ReadWritePaths=` +— we pass `GH_TOKEN` inline to every `gh` invocation in +`run_daily.sh`, so the host gh-cli config is unused. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +SERVICE = ( + REPO_ROOT / "tests" / "claude_code" / "cron_vm" / "litellm-compat-matrix.service" +) + + +def _service_text() -> str: + return SERVICE.read_text() + + +def _directive(name: str) -> str: + """Return the value of a single-line systemd directive (or empty).""" + text = _service_text() + match = re.search(rf"^\s*{re.escape(name)}\s*=\s*(.*)$", text, re.MULTILINE) + return match.group(1).strip() if match else "" + + +def test_inaccessible_paths_hides_credential_dotdirs() -> None: + """Every credential-bearing dotdir must be under `InaccessiblePaths=`.""" + inaccessible = _directive("InaccessiblePaths") + assert inaccessible, ( + "litellm-compat-matrix.service: must declare `InaccessiblePaths=` " + "to hide credential dotdirs from the `claude` subprocess and the " + "model-directed Read tool. Without this, an absolute-path read " + "like `Read('/home/mateo/.config/gh/hosts.yml')` exfiltrates " + "the gh-cli token despite the per-invocation HOME isolation." + ) + for path in ( + "/home/mateo/.config/gh", + "/home/mateo/.ssh", + "/home/mateo/.aws", + "/home/mateo/.docker", + "/home/mateo/.kube", + "/home/mateo/.gnupg", + ): + # Tolerated `-` prefix means "ignore if missing on host". + assert path in inaccessible, ( + f"litellm-compat-matrix.service: `{path}` must appear in " + f"`InaccessiblePaths=` so the cron `claude` subprocess can " + f"never read it (even via an absolute path that bypasses " + f"the per-invocation HOME override)." + ) + + +def test_gh_config_is_not_writeable() -> None: + """`~/.config/gh` is not whitelisted under `ReadWritePaths=`. + + We pass `GH_TOKEN` inline to every `gh` invocation in + `run_daily.sh` (`gh repo clone`, `gh pr create`, `gh pr edit`). + The host `~/.config/gh/hosts.yml` is therefore never consulted + or written to. Keeping it out of `ReadWritePaths=` is the second + line of defense: a future regression that drops the inline-token + convention will fail loudly (gh writes a new login config and + hits a read-only filesystem) rather than silently re-introduce + the credential exfiltration surface that + `InaccessiblePaths=/home/mateo/.config/gh` is closing. + """ + rw = _directive("ReadWritePaths") + assert ".config/gh" not in rw, ( + "litellm-compat-matrix.service: `/home/mateo/.config/gh` must " + "*not* appear in `ReadWritePaths=`. We pass `GH_TOKEN` inline " + "to every `gh` invocation in run_daily.sh, so the host gh-cli " + "config is never consulted or written to. Keeping the path out " + "of ReadWritePaths means a future regression that drops the " + "inline-token convention will fail loudly instead of silently " + "re-opening the credential exfiltration surface that " + "`InaccessiblePaths=` is closing." + ) + + +def test_protect_home_is_read_only_or_stricter() -> None: + """`ProtectHome=` must be at least `read-only`.""" + value = _directive("ProtectHome") + assert value in ("read-only", "tmpfs", "yes", "true"), ( + f"litellm-compat-matrix.service: `ProtectHome=` must be `read-only`, " + f"`tmpfs`, or `yes`. Got: {value!r}. Without this, the unit can " + f"write anywhere under /home/mateo, including overwriting " + f"~/.config/gh/hosts.yml." + ) diff --git a/tests/claude_code/cli_driver.py b/tests/claude_code/cli_driver.py index 1bcb96d817f..1a56318a67a 100644 --- a/tests/claude_code/cli_driver.py +++ b/tests/claude_code/cli_driver.py @@ -15,8 +15,10 @@ from __future__ import annotations import json import os +import shutil import subprocess import sys +import tempfile import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field @@ -39,18 +41,25 @@ DEFAULT_TIMEOUT_SECONDS = float( ) # Env vars the `claude` Node CLI legitimately needs to function: -# locating its own binary + node, finding HOME for ~/.claude config, -# basic locale/terminal plumbing. Deliberately excludes every -# credential-bearing var that the surrounding CI job sets for the -# proxy (ANTHROPIC_API_KEY, AWS_*, AZURE_*, VERTEXAI_CREDENTIALS, -# GITHUB_TOKEN, OPENAI_API_KEY, DATABASE_URL, ...). The CLI talks to -# the proxy via the explicit ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN -# we set below — it has no business reading the proxy's upstream -# credentials, and a compromised CLI release shouldn't be able to -# exfiltrate them out of the CI environment. +# locating its own binary + node, basic locale/terminal plumbing. +# Deliberately excludes every credential-bearing var that the +# surrounding CI job sets for the proxy (ANTHROPIC_API_KEY, AWS_*, +# AZURE_*, VERTEXAI_CREDENTIALS, GITHUB_TOKEN, OPENAI_API_KEY, +# DATABASE_URL, ...). The CLI talks to the proxy via the explicit +# ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN we set below — it has no +# business reading the proxy's upstream credentials, and a +# compromised CLI release shouldn't be able to exfiltrate them out +# of the CI environment. +# +# `HOME` is intentionally NOT in this list: see `_make_isolated_home` +# below. We give the CLI a fresh empty per-invocation HOME so a +# compromised claude package or a model-directed `Read` tool call +# can't reach files like `~/.config/gh/hosts.yml`, `~/.ssh/id_rsa`, +# or `~/.bash_history` on the cron VM (and on the CircleCI executor +# the same isolation prevents accidentally exposing checkout-adjacent +# files even though the runner home is ephemeral there). _CLI_ENV_ALLOWLIST: tuple = ( "PATH", - "HOME", "USER", "LOGNAME", "SHELL", @@ -65,6 +74,29 @@ _CLI_ENV_ALLOWLIST: tuple = ( ) +def _make_isolated_home() -> str: + """Create a fresh empty HOME directory for a single `claude` subprocess. + + The CLI needs *a* writable HOME (it caches per-session state under + `$HOME/.claude/projects//`), but it has no legitimate need + for the *user's* HOME. Handing it the real one means a compromised + `@anthropic-ai/claude-code` release, or a model-directed `Read` + tool call during a PDF/vision cell, can read host files like + `~/.config/gh/hosts.yml` (GitHub CLI host token), `~/.ssh/`, + `~/.bash_history`, or any other dotfile under the runtime user's + home. On the cron VM the runtime user is a real interactive + account (`mateo`) with a populated home directory, so this is a + real exfiltration surface. + + The directory is created under `tempfile.gettempdir()` (which is + `/tmp` on Linux; under systemd's `PrivateTmp=true` that's a + per-service tmpfs that the service user can't otherwise reach). + Caller is responsible for `shutil.rmtree`-ing it after the + subprocess exits. + """ + return tempfile.mkdtemp(prefix="claude-cli-home-") + + class ClaudeCLIError(RuntimeError): """Raised when the `claude` CLI cannot be invoked or returns a fatal error.""" @@ -171,6 +203,12 @@ def run_claude( } env["ANTHROPIC_BASE_URL"] = base_url env["ANTHROPIC_AUTH_TOKEN"] = api_key + # Hand the CLI a fresh empty HOME so a compromised claude package + # or a model-directed Read tool call can't see the runtime user's + # real dotfiles. Created here, removed in the `finally` below + # regardless of how the subprocess exits. + isolated_home = _make_isolated_home() + env["HOME"] = isolated_home if extra_env: env.update(extra_env) @@ -185,21 +223,31 @@ def run_claude( run_fn = runner or subprocess.run try: - completed = run_fn( - cmd, - env=env, - input=stdin_input, - capture_output=True, - text=True, - timeout=timeout, - check=False, - ) - except FileNotFoundError as exc: - raise ClaudeCLIError( - f"claude CLI not found at {cli_path!r}; install with `npm i -g @anthropic-ai/claude-code`" - ) from exc - except subprocess.TimeoutExpired as exc: - raise ClaudeCLIError(f"claude CLI timed out after {timeout}s") from exc + try: + completed = run_fn( + cmd, + env=env, + input=stdin_input, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise ClaudeCLIError( + f"claude CLI not found at {cli_path!r}; install with `npm i -g @anthropic-ai/claude-code`" + ) from exc + except subprocess.TimeoutExpired as exc: + raise ClaudeCLIError(f"claude CLI timed out after {timeout}s") from exc + finally: + # Best-effort cleanup. If the subprocess wrote a `.claude/` + # session dir under the isolated HOME, we remove it here so + # parallel matrix runs don't accumulate per-call tmpdirs. + # `ignore_errors=True` because rmtree races with any + # not-yet-reaped child (SIGTERM'd `claude` on a host-side + # timeout) are benign — the next matrix run starts from a + # fresh tmpdir anyway. + shutil.rmtree(isolated_home, ignore_errors=True) events = _parse_stream_json(completed.stdout or "") text = _extract_assistant_text(events) diff --git a/tests/claude_code/cron_vm/litellm-compat-matrix.service b/tests/claude_code/cron_vm/litellm-compat-matrix.service index 01f0de301de..fb56dea3d99 100644 --- a/tests/claude_code/cron_vm/litellm-compat-matrix.service +++ b/tests/claude_code/cron_vm/litellm-compat-matrix.service @@ -73,27 +73,69 @@ Restart=no # the env-file; everything else it writes lives in either the worktree # (managed) or `/tmp` (cleaned up by tempfile). # +# `ProtectHome=read-only` blocks writes to /home/mateo but still +# allows reads. That's safe for the trusted run_daily.sh script +# itself, but unsafe for any subprocess we don't control: a +# compromised npm-installed `claude` package, or a model-directed +# `Read` tool call during a PDF/vision cell, could read sensitive +# host files like `~/.config/gh/hosts.yml` (gh-host token), +# `~/.ssh/`, or `~/.bash_history`. We mitigate that at the call +# boundary: every `claude` subprocess (the up-front `claude --version` +# probe in run_daily.sh, plus every CLI invocation routed through +# tests/claude_code/cli_driver.py) runs with `HOME` pointed at a +# fresh empty per-invocation tmpdir, not at /home/mateo. The CLI +# never sees the runtime user's real dotfiles. `gh` invocations in +# run_daily.sh pass `GH_TOKEN` inline, so they never need to read +# ~/.config/gh either; that path is intentionally NOT in the +# whitelist below — keeping it out is the second line of defense if +# the inline-token convention is ever accidentally regressed. +# # ReadWritePaths whitelist: # * litellm-cron-worktree - the long-lived stable-tag checkout + # its `.venv` (`uv sync` rewrites every # run) + `.uv-bin` (pinned `uv` binary # cache). # * .cache - uv's wheel cache (~/.cache/uv) so we -# don't redownload pinned deps each run. -# * .claude - `claude` CLI's per-session state under -# `~/.claude/projects//`; created -# on every `claude --print` invocation. -# * .config/gh - `gh` CLI host config; technically not -# needed when we pass GH_TOKEN inline, -# but cheap to whitelist and prevents -# future regressions if a code path -# ever falls back to the host config. -# * /tmp - mktemp -d workdir + proxy logs. +# don't redownload pinned deps each +# run. Used only by the trusted `uv` +# process; not exposed to `claude`. +# * /tmp - mktemp -d workdir, proxy logs, and +# the per-`claude`-invocation isolated +# HOME tmpdirs. PrivateTmp=true below +# gives the service its own tmpfs view +# so these don't escape to the host. NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only -ReadWritePaths=/home/mateo/litellm-cron-worktree /home/mateo/.cache /home/mateo/.claude /home/mateo/.config/gh /tmp +ReadWritePaths=/home/mateo/litellm-cron-worktree /home/mateo/.cache /tmp PrivateTmp=true +# Filesystem-level hiding for credential-bearing dotdirs/files. Even +# though `ProtectHome=read-only` prevents writes, a model-directed +# `Read` tool call (the PDF cells pass `--allowed-tools Read`) or a +# compromised `claude` package can read absolute paths under +# /home/mateo and exfiltrate the contents. `InaccessiblePaths=` makes +# the listed paths look like empty/missing to every process in the +# unit's mount namespace -- including the trusted populator script, +# which is fine because it doesn't need any of these: +# +# * .config/gh - gh CLI host token; we pass GH_TOKEN inline to +# every `gh` invocation (clone/PR/reviewer) so the +# host config is never consulted. +# * .ssh - never used by the populator. +# * .aws - upstream AWS credentials are passed to the proxy +# via the EnvironmentFile (provider env vars), not +# via shared SDK config files. +# * .docker - the populator never talks to a docker socket. +# * .kube - the populator never talks to a k8s API. +# * .gnupg - no GPG signing on the bot's commits. +# +# Leading `-` makes systemd tolerant if a path doesn't exist on the +# host (the unit is portable across VMs that may not have all of +# them set up). Anything else under /home/mateo (the litellm +# checkout, the cron worktree, the uv cache, .local/bin for the +# claude/uv/gh binaries on PATH) stays read-accessible. +InaccessiblePaths=-/home/mateo/.config/gh -/home/mateo/.ssh -/home/mateo/.aws -/home/mateo/.docker -/home/mateo/.kube -/home/mateo/.gnupg + [Install] WantedBy=multi-user.target diff --git a/tests/claude_code/cron_vm/run_daily.sh b/tests/claude_code/cron_vm/run_daily.sh index b89d211f905..a70c9678d9b 100755 --- a/tests/claude_code/cron_vm/run_daily.sh +++ b/tests/claude_code/cron_vm/run_daily.sh @@ -173,9 +173,20 @@ log "resolved litellm: ${LITELLM_VERSION}" # the proxy or test harness ever starts. Probe under `env -i` with the # same minimal allowlist the PR-gate uses (the matrix run itself goes # through cli_driver.py, which already scrubs the CLI env). +# +# The probe also runs under a fresh empty HOME instead of the runtime +# user's real $HOME. `ProtectHome=read-only` in the systemd unit +# blocks *writes* to /home/mateo but still allows reads, so a +# compromised claude package invoked here with HOME=/home/mateo could +# read ~/.config/gh/hosts.yml (the gh-host token), ~/.bash_history, +# or ~/.ssh/. Pointing HOME at a per-run dir under ${WORKDIR} hides +# those entirely from the subprocess; ${WORKDIR} is rm -rf'd by the +# script-wide cleanup() trap regardless of probe outcome. +CLAUDE_PROBE_HOME="${WORKDIR}/claude-probe-home" +mkdir -p "${CLAUDE_PROBE_HOME}" CLAUDE_CODE_VERSION="$(env -i \ PATH="${PATH}" \ - HOME="${HOME}" \ + HOME="${CLAUDE_PROBE_HOME}" \ USER="${USER:-mateo}" \ TERM="${TERM:-dumb}" \ LANG="${LANG:-C.UTF-8}" \ @@ -385,7 +396,14 @@ FORK_OWNER="${FORK_OWNER:-agent-shin}" FORK_REPO="${FORK_REPO:-${FORK_OWNER}/litellm-docs}" log "cloning ${DOCS_REPO}@${DOCS_BRANCH}" -gh repo clone "${DOCS_REPO}" "${DOCS_CLONE}" -- --depth 1 --branch "${DOCS_BRANCH}" +# Use the agent-shin token inline rather than the host gh-cli config. +# `BerriAI/litellm-docs` is a public repo so unauthenticated clone +# would also work, but passing the token explicitly means the systemd +# unit can hide `~/.config/gh` (`InaccessiblePaths=`) without breaking +# this clone — closing the model-directed `Read("/home/mateo/.config/gh/...")` +# exfiltration path on the cron VM. +GH_TOKEN="${AGENT_SHIN_GITHUB_TOKEN}" \ + gh repo clone "${DOCS_REPO}" "${DOCS_CLONE}" -- --depth 1 --branch "${DOCS_BRANCH}" cd "${DOCS_CLONE}" git config user.email "litellm-bot@berri.ai"