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:
Cursor Agent 2026-05-19 05:49:18 +00:00
parent 0cd506f24d
commit 5ad351bbd2
No known key found for this signature in database
6 changed files with 413 additions and 41 deletions

View file

@ -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."""

View file

@ -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."
)

View file

@ -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."
)

View file

@ -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)

View file

@ -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

View file

@ -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"