Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"PREK_HOME": "~/.cache/prek-sandbox"
},
"sandbox": {
"enabled": true,
"excludedCommands": [
Expand Down Expand Up @@ -106,6 +109,7 @@
"Edit(~/.claude/plugins/cache/apache-magpie/magpie-vetted-ops/**)",
"Edit(~/.claude/magpie/**)",
"Edit(~/.config/uv/**)",
"Edit(~/.cache/prek/**)",
"Edit(~/.claude/plugins/cache/apache-magpie/magpie-adversarial-review/**)",
"Edit(~/.claude/scripts/**)",
"Bash(curl *)",
Expand Down
4 changes: 4 additions & 0 deletions docs/setup/sandbox-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,10 @@ uv tool install prek

Do not add `~/.local/share/uv` back to `allowWrite`: `sandbox-lint` rejects it.

The same switch moved sandboxed prek to its own home, `~/.cache/prek-sandbox`
(`env.PREK_HOME`), so the first sandboxed `prek run` after it rebuilds its hook
environments once; that is expected, not a failure.

---

## Git hooks silently skipped for commits made inside the sandbox
Expand Down
4 changes: 4 additions & 0 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,10 @@ uv tool upgrade --all --reinstall

The old `~/.cache/uv/` stays as the sandbox's cache; the host never reads it again.

**prek has the same problem.** The git hooks of a commit you make in a terminal run prek outside the sandbox, from hook environments under its home: `~/.cache/prek/` by default, which the `~/.cache/` grant makes sandbox-writable. Nothing excluded from the sandbox runs prek, so the settings `env` (which only Claude Code's own commands see, never your terminal) does the split: `"PREK_HOME": "~/.cache/prek-sandbox"` gives sandboxed prek a home of its own, and `Edit(~/.cache/prek/**)` keeps sandboxed commands out of the terminal's. prek expands the `~`. The first sandboxed `prek run` after the switch rebuilds its hook environments there once. `sandbox-lint` fails a settings file whose `PREK_HOME` is missing or overlaps `~/.cache/prek`.

The `env` also reaches a command Claude Code runs *outside* the sandbox (an approved bypass, often a signed commit). The whole-user [git-hook dispatcher](#whole-user-with-the-per-repo-dispatcher) covers that: a hook it runs with no `SANDBOX_RUNTIME` in the environment gets `PREK_HOME=~/.local/share/prek-host` and a host-only `XDG_CACHE_HOME` (for the pip, go, npm and uv caches the hooks use), whatever the env said. It only runs when git reaches it: a repository-level `core.hooksPath` (some `prek install` runs set one) overrides the global one and skips the dispatcher. magpie-setup's session-start hook removes one that points at the repository's own `.git/hooks` (redundant, since the dispatcher chains there anyway) and says so; any other value is reported, and `setup-isolated-setup-verify` flags it. With per-project scope there is no dispatcher, so do not bypass the sandbox for a commit there.

Projects set up before the switch may still carry the old write grant. `sandbox-add-project-root.sh` removes it whenever it runs (on checkout, adopt and upgrade), and magpie-setup's session-start hook removes it from the checkout's `.claude/settings.local.json` the next time a session opens there, and says so. A linked worktree's main checkout is cleaned when a session opens in it: the worktree's `.git` file is sandbox-writable, so the hook never follows it. A broader grant, or one in user-scope or committed settings, is reported for you to remove; those files are yours.
`sandbox-lint` fails a settings file that makes `~/.local/share/uv` writable or lacks the two `~/.config/uv` deny rules; `setup-isolated-setup-verify` checks the config file itself.

Expand Down
11 changes: 10 additions & 1 deletion plugins/magpie-setup/skills/isolated-setup-install/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ when_to_use: >-
capability: capability:platform
surface_hash: sha256:6358702510d21c26
license: Apache-2.0
measured_tokens: 7894
measured_tokens: 8036
---

<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
Expand Down Expand Up @@ -491,6 +491,15 @@ can write:
framework's `.claude/settings.json`, so Step 2's copy brings them). Sandboxed
uv then keeps its default, writable cache.

- **Sandboxed prek gets its own home.** The settings carry
`"env": {"PREK_HOME": "~/.cache/prek-sandbox"}` and `permissions.deny`
carries `Edit(~/.cache/prek/**)` (both in the framework's
`.claude/settings.json`): a terminal's git hooks run prek outside the
sandbox from `~/.cache/prek`. The whole-user dispatcher (Step P) gives hooks
that Claude Code runs outside the sandbox a host-only home and cache root
too; if a repository sets its own `core.hooksPath`, propose unsetting it so
the dispatcher runs there.

Then hand the operator the reinstall commands from the doc to run in a
terminal: interpreters and tools the sandbox could write before may already
have been changed. Rationale and commands:
Expand Down
4 changes: 2 additions & 2 deletions plugins/magpie-setup/skills/isolated-setup-update/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ when_to_use: >-
capability: capability:platform
surface_hash: sha256:1f327e069312dad2
license: Apache-2.0
measured_tokens: 6526
measured_tokens: 6557
---

<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
Expand Down Expand Up @@ -170,7 +170,7 @@ Walk each:
Drafting keeps working through the `oauth-draft` MCP server, which runs outside the sandbox; the `oauth-draft-*` CLIs need an approved run or a per-machine absolute-path exclusion ([`docs/setup/secure-agent-setup.md` → The framework's own `.claude/settings.json`](../../../../docs/setup/secure-agent-setup.md#the-frameworks-own-claudesettingsjson)).
- **`~/.local/share/uv` writable, or no host-only uv cache.**
Earlier defaults put `~/.local/share/uv/` in `allowWrite`, where the interpreters and tool venvs that hooks, MCP servers and the excluded `uvx` / `uv run` commands run outside the sandbox live; and `uvx` ran from the shared, sandbox-writable `~/.cache/uv`.
Report a write grant covering `~/.local/share/uv`, a `~/.config/uv/uv.toml` without an absolute host `cache-dir`, or a missing `Read(~/.config/uv/uv.toml)` / `Edit(~/.config/uv/**)` deny as a **must-fix**; the remedy is `setup-isolated-setup-install` Step U ([Host-only uv state](../../../../docs/setup/secure-agent-setup.md#host-only-uv-state)).
Report a write grant covering `~/.local/share/uv`, a `~/.config/uv/uv.toml` without an absolute host `cache-dir`, a missing `Read(~/.config/uv/uv.toml)` / `Edit(~/.config/uv/**)` / `Edit(~/.cache/prek/**)` deny, or an `env.PREK_HOME` that is missing or overlaps `~/.cache/prek`, as a **must-fix**; the remedy is `setup-isolated-setup-install` Step U ([Host-only uv state](../../../../docs/setup/secure-agent-setup.md#host-only-uv-state)).
- **`enableWeakerNetworkIsolation: true`.**
It is not there for lychee, which runs offline.
It lets native-TLS CLI tools verify TLS through the sandbox's TLS-terminating proxy — the mechanism the schema notes for `gh` / `gcloud` / `terraform`.
Expand Down
19 changes: 14 additions & 5 deletions plugins/magpie-setup/skills/isolated-setup-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ when_to_use: >-
capability: capability:platform
surface_hash: sha256:ce33cb9437d44992
license: Apache-2.0
measured_tokens: 7119
measured_tokens: 7317
---

<!-- Placeholder convention (see AGENTS.md#placeholder-convention-used-in-skill-files):
Expand Down Expand Up @@ -231,11 +231,11 @@ Walk each in order:
a local value that overrides one the committed file sets (such as
`sandbox.enabled`) is ✗.

19. **Host-only uv state.** Only when the sandbox is enabled; otherwise **n/a**.
19. **Host-only uv and prek state.** Only when the sandbox is enabled; otherwise **n/a**.
Hooks, MCP servers, the excluded `uvx` / `uv run` commands and the
operator's terminal run uv-managed code outside the sandbox, so none of
it may come from a directory a sandboxed command can write. Three
sub-checks, each ✓ or ✗:
operator's terminal (its git hooks included) run uv- and prek-managed
code outside the sandbox, so none of it may come from a directory a
sandboxed command can write. Four sub-checks, each ✓ or ✗:

- **19a — the uv data dir is read-only.** No `allowWrite` or
`additionalDirectories` entry in any scope covers `~/.local/share/uv`
Expand All @@ -249,6 +249,15 @@ Walk each in order:
`Read(~/.config/uv/uv.toml)` and `Edit(~/.config/uv/**)`, and a
sandboxed `uv cache dir` prints uv's default cache, not 19b's. Printing
19b's path is ✗: the sandbox reads the config and writes the host cache.
- **19d — sandboxed prek has its own home.** The merged settings carry
`env.PREK_HOME` outside `~/.cache/prek` (`~/.cache/prek-sandbox` in the
reference) and `permissions.deny` carries `Edit(~/.cache/prek/**)`; a
sandboxed write probe into `~/.cache/prek` fails. Either missing is ✗: a
terminal's git hooks run prek's environments there, outside the sandbox.
With the whole-user dispatcher, an installed copy without the
`prek-host` block is ✗ (stale: commits Claude runs outside the sandbox
would use the sandbox home). Per-project scope, or a repository whose own
`core.hooksPath` skips the dispatcher (check 8), is ⚠ for that same case.

[Why](../../../../docs/setup/secure-agent-setup.md#host-only-uv-state).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ reading this file; read the section for a check whose condition holds.
Do not report them as inert; that is the simple flavour's
cost, which this flavour exists to remove.

- ⚠ for each repository that sets its own `core.hooksPath`
(`git config --local --get core.hooksPath`, often left by a
`prek install`): it overrides the global one, so git never runs the
dispatcher there, and neither its post-checkout allowlist sync nor its
prek-home switch happens. magpie-setup's session-start hook removes
a value pointing at the repository's own `.git/hooks` by itself; for
any other, the remediation is `git config --local --unset
core.hooksPath` in that repository, run by the operator; the
dispatcher still chains to the repository's own `.git/hooks/`.

If `core.hooksPath` is unset (or points elsewhere), the
operator is in **per-project** scope (the default). No further
sub-check needed — the per-project mode is fully covered by
Expand Down
201 changes: 198 additions & 3 deletions plugins/magpie-setup/skills/setup/setup_preflight/isolated.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
import os
import re
import secrets
import stat
import subprocess
import sys
from datetime import date
from pathlib import Path
Expand Down Expand Up @@ -403,6 +405,195 @@ def _report_covering_grants(settings: Path, base: Path, home: Path, removable: f
]


def _git_config(home: Path, *args: str) -> str | None:
"""`git config <args>` for the user's own global config, run from $HOME.

Only `--global` reads go through here; a repository's config is parsed
directly (`_rewrite_repo_config`), never handed to git.
"""
env = {
k: v
for k, v in os.environ.items()
if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG")
}
try:
done = subprocess.run(
["git", "config", *args],
cwd=home,
env=env,
capture_output=True,
text=True,
check=False,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return None
return done.stdout.strip() if done.returncode == 0 else None


def _resolved(path: str, base: Path, home: Path) -> Path:
text = str(home) + path[1:] if path == "~" or path.startswith("~/") else path
candidate = Path(text) if text.startswith("/") else base / text
return Path(os.path.realpath(candidate))


_SECTION_RE = re.compile(r'^\s*\[\s*([A-Za-z0-9.-]+)\s*(?:"([^"]*)")?\s*\](.*)$')
_HOOKS_PATH_RE = re.compile(r"^\s*hookspath\s*=\s*(.*?)\s*$", re.I)


def _shown(value: str) -> str:
"""A config value fit for a message the model reads: quoted, short, no control characters."""
cleaned = "".join(ch for ch in value if ch.isprintable())[:120]
return repr(cleaned)


def _core_hooks_path_lines(text: str) -> tuple[list[int], list[str]] | None:
"""Indices and values of `[core] hooksPath` lines, or None when the file is not plain enough.

Only a plain file is rewritten: an `include` directive, a continued or
unparsable line means the value may come from elsewhere, so the caller only
reports.
"""
indices: list[int] = []
values: list[str] = []
section = ""
for number, line in enumerate(text.splitlines()):
stripped = line.strip()
if not stripped or stripped[0] in "#;":
continue
header = _SECTION_RE.match(line)
if header:
rest = header.group(3).strip()
# Git allows a key after the header on the same line; anything but a
# comment there is not plain enough to rewrite.
if rest and rest[0] not in "#;":
return None
name = header.group(1).lower()
if name in ("include", "includeif"):
return None
section = name if header.group(2) is None else f"{name}.{header.group(2)}"
continue
if stripped.startswith("["):
return None # a header this parser does not understand
if line.rstrip().endswith("\\"):
return None
match = _HOOKS_PATH_RE.match(line)
if section == "core" and not line.lstrip().startswith("[") and match:
value = match.group(1)
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
indices.append(number)
values.append(value)
return indices, values


def _rewrite_repo_config(checkout: Path, home: Path, own_hooks: Path) -> list[str]:
"""Drop `core.hooksPath` from `<checkout>/.git/config` when it names `own_hooks`.

The checkout is sandbox-writable and this runs outside the sandbox, so no
git command touches it: `.git` and `config` are opened without following a
symlink through one directory descriptor, and the rewrite goes to a fresh
temporary file renamed inside that directory.
"""
try:
cfd = os.open(checkout, os.O_RDONLY | _DIRECTORY)
except OSError:
return []
try:
try:
gfd = os.open(".git", os.O_RDONLY | _DIRECTORY | _NOFOLLOW, dir_fd=cfd)
except OSError:
return [] # a linked worktree's .git file, a symlink, or none
try:
try:
fd = os.open("config", os.O_RDONLY | _NOFOLLOW, dir_fd=gfd)
except OSError:
return []
original_mode = stat.S_IMODE(os.fstat(fd).st_mode)
with os.fdopen(fd, encoding="utf-8", errors="strict") as handle:
try:
text = handle.read()
except UnicodeDecodeError:
text = None
config = checkout / ".git" / "config"
if text is None:
return [
f"{config} could not be read as text; check that its core.hooksPath skips no hooks you need"
]
parsed = _core_hooks_path_lines(text)
if parsed is None:
return [
f"{config} may set core.hooksPath (it uses includes); check it skips no hooks you need"
]
indices, values = parsed
if not values:
return []
if len(values) == 1 and _resolved(values[0], checkout, home) == own_hooks:
lines = text.splitlines(keepends=True)
kept = "".join(line for number, line in enumerate(lines) if number != indices[0])
# The result must read back plain and without the key, or nothing is written.
if _core_hooks_path_lines(kept) != ([], []):
return [f"{config} sets core.hooksPath={_shown(values[0])}; unset it by hand"]
tmp_name = f".config.{secrets.token_hex(8)}.tmp"
try:
tfd = os.open(
tmp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW, 0o600, dir_fd=gfd
)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
except OSError:
return [f"{config} sets core.hooksPath={_shown(values[0])}; unset it by hand"]
try:
with os.fdopen(tfd, "w", encoding="utf-8") as handle:
handle.write(kept)
# The config keeps the mode it had, whatever this process's umask.
os.fchmod(handle.fileno(), original_mode)
os.replace(tmp_name, "config", src_dir_fd=gfd, dst_dir_fd=gfd)
except OSError:
with contextlib.suppress(OSError):
os.unlink(tmp_name, dir_fd=gfd)
return [f"{config} sets core.hooksPath={_shown(values[0])}; unset it by hand"]
return [
f"removed core.hooksPath={_shown(values[0])} from {config}; the dispatcher chains to it anyway"
]
return [
f"{config} sets core.hooksPath={_shown(values[-1])}, which skips the whole-user dispatcher "
"(and its host-only prek home); unset it unless you need it"
]
finally:
os.close(gfd)
finally:
os.close(cfd)


def drop_redundant_hooks_path(root: Path, home: Path | None = None) -> list[str]:
"""Remove a repository's own `core.hooksPath` that hides the whole-user dispatcher.

With the whole-user dispatcher installed (global `core.hooksPath` at
`~/.claude/git-hooks`, its `post-checkout` the dispatcher), a repository
that sets `core.hooksPath` to its own `.git/hooks` (some `prek install`
runs do) skips the dispatcher, and with it the host-only prek home for
hooks run outside the sandbox. The dispatcher chains to `.git/hooks`
anyway, so that value is redundant and is removed; any other value is only
reported. Main checkouts only: a linked worktree's config lives in a common
directory its sandbox-writable `.git` file names.
"""
home = home if home is not None else Path.home()
hooks_dir = home / ".claude" / "git-hooks"
global_path = _git_config(home, "--global", "--get", "core.hooksPath")
if not global_path or _resolved(global_path, home, home) != Path(os.path.realpath(hooks_dir)):
return []
if Path(os.path.realpath(hooks_dir / "post-checkout")).name != "git-hook-dispatcher.sh":
return []
checkout = _checkout(root)
return _rewrite_repo_config(checkout, home, Path(os.path.realpath(checkout / ".git" / "hooks")))


#: Shown when the session start removed or found a repository-level hooks path.
HOOKS_PATH_HEADER = (
"Apache Magpie: a repository-level core.hooksPath bypasses the whole-user git-hook dispatcher, "
"so hooks run outside the sandbox would use prek environments the sandbox can write (apache/magpie#1792)."
)


#: Shown when the session start removed or found a `~/.local/share/uv` grant.
STALE_UV_HEADER = (
"Apache Magpie: ~/.local/share/uv holds the interpreters and tools uv runs outside the sandbox, "
Expand All @@ -421,13 +612,17 @@ def session_start(root: Path, notice: str = "", home: Path | None = None) -> str
warn about.

Every session start also drops the old `~/.local/share/uv` write grant
(`drop_stale_uv_writes`), adopted or not, and tells the user what it did.
(`drop_stale_uv_writes`) and a repository-level `core.hooksPath` that
only hides the whole-user dispatcher (`drop_redundant_hooks_path`),
adopted or not, and tells the user what it did.
"""
stale = drop_stale_uv_writes(root.resolve(), home)
stale_message = "\n".join([STALE_UV_HEADER, *(f"- {m}" for m in stale)]) if stale else ""
hooks = drop_redundant_hooks_path(root.resolve(), home)
hooks_message = "\n".join([HOOKS_PATH_HEADER, *(f"- {m}" for m in hooks)]) if hooks else ""
project = _adopted_root(root.resolve())
messages = [stale_message] if stale_message else []
contexts = [part for part in (notice, stale_message) if part]
messages = [m for m in (stale_message, hooks_message) if m]
contexts = [part for part in (notice, stale_message, hooks_message) if part]
if project is not None and not warning_opted_out(project) and not configured(project, home):
messages.append(UNCONFIGURED_WARNING)
contexts.append(UNCONFIGURED_CONTEXT)
Expand Down
Loading
Loading