mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
fix(cron_vm): veria — isolate $HOME and hide credential dotdirs from claude
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 <mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
0cd506f24d
commit
5ad351bbd2
6 changed files with 413 additions and 41 deletions
|
|
@ -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/<sha>/`), 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."""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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/<sha>/`), 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)
|
||||
|
|
|
|||
|
|
@ -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/<sha>/`; 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue