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
11 changes: 3 additions & 8 deletions docs/setup/secure-agent-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2877,7 +2877,7 @@ Three events, one script:
- `SessionEnd` → `stop` ends it.
- `PreToolUse` on `Bash` → `ensure` starts it again before a `docker` or `podman` command when it is gone mid-session.
It exits after its idle timeout (default 4 h), when another session on the same project ends and stops it, or when it or its backend dies; without `ensure` it stays down until the next session.
The two `if` filters keep the hook off every other command; they are best-effort, so the script checks the command itself as well and leaves at once when it names neither CLI.
It is registered for every `Bash` call, with no `if` filter: a filter such as `Bash(docker *)` would miss `docker-compose`, `podman-compose` and `/usr/local/bin/docker`. The script reads the command itself and leaves within about 10 ms when it names neither CLI.
When the gateway is already running the check costs about half a second, on container commands only.
`ensure` runs every check `start` does, so it can start nothing that `start` would refuse.
It judges the directory the session started in, which Claude Code hands every hook as `CLAUDE_PROJECT_DIR`, never the directory the agent has `cd`'d into: judged from another directory, the session project's own write grants would go uncounted.
Expand All @@ -2894,10 +2894,7 @@ Three events, one script:
{ "hooks": [ { "type": "command", "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh stop" } ] }
],
"PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "if": "Bash(docker *)", "timeout": 30, "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh ensure" },
{ "type": "command", "if": "Bash(podman *)", "timeout": 30, "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh ensure" }
] }
{ "matcher": "Bash", "hooks": [ { "type": "command", "timeout": 30, "command": "/Users/<you>/.claude/scripts/container-gateway-hook.sh ensure" } ] }
]
}
}
Expand All @@ -2910,9 +2907,7 @@ jq --arg h "$(cd ~ && pwd -P)" '
($h+"/.claude/scripts/container-gateway-hook.sh") as $s
| .hooks.SessionStart += [{"hooks":[{"type":"command","command":($s+" start")}]}]
| .hooks.SessionEnd += [{"hooks":[{"type":"command","command":($s+" stop")}]}]
| .hooks.PreToolUse += [{"matcher":"Bash","hooks":[
{"type":"command","if":"Bash(docker *)","timeout":30,"command":($s+" ensure")},
{"type":"command","if":"Bash(podman *)","timeout":30,"command":($s+" ensure")}]}]
| .hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","timeout":30,"command":($s+" ensure")}]}]
' ~/.claude/settings.local.json > ~/.claude/settings.local.json.new && mv ~/.claude/settings.local.json.new ~/.claude/settings.local.json
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,7 @@ until you delete that record (the log line names it).
**L.2 — Hooks.** Wire a `SessionStart` hook running
`container-gateway-hook.sh start`, a `SessionEnd` hook running
`container-gateway-hook.sh stop`, and a `PreToolUse` hook (matcher
`Bash`, two handlers with `if` set to `Bash(docker *)` and
`Bash(podman *)`, `timeout` 30) running
`Bash`, no `if` filter, `timeout` 30) running
`container-gateway-hook.sh ensure`, which starts the gateway again
before a container command when it exited mid-session, into the user
settings, each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:a19871219995dc10"
FRAMEWORK_FINGERPRINT = "sha256:712e4acb2792c387"
2 changes: 1 addition & 1 deletion tools/agent-isolation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ per runtime — see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a
| [`gpg-touch-overlay.sh`](gpg-touch-overlay.sh) | Claude Code `PreToolUse`/`PostToolUse` hook (Bash matcher). Puts a window on screen while a hardware signing key blocks waiting for a touch — the case pinentry never prompts for, and which is indistinguishable from a hung `git commit`. `arm` starts a watcher before a git command that could reach the key — one that signs, or one that talks to a remote over ssh; the watcher shows the window only once the key has actually blocked, stays quiet while pinentry owns the screen, and `disarm` tears the whole process group down afterwards. Watches both signing commands — `gpg`, and the `ssh-keygen -Y sign` git runs under `gpg.format=ssh` — and, for the authentication touch a `git pull` / `push` / `fetch` over ssh asks for, the connection ssh holds open to the agent's socket while its request is out. Neither `arm` nor `wrap` starts a watcher when the card's signature and authentication touch policies are both off (`gpg --card-status --with-colons`, cached for ten minutes, or one for a both-off answer); an unknown policy keeps it. `wrap` is the same watcher for git commands the agent never runs — a commit or push from the operator's own terminal: git is pointed at the script as its signing program (`gpg.ssh.program` via the argument-free `gpg-touch-wrap-ssh-keygen` symlink, or `gpg.program`) and its ssh command (`core.sshCommand … wrap ssh`), and the script runs the real program with a watcher alive for exactly that long. The window names the blocked command and the directory it runs in — recorded per signing context under `context/` beside the registration, re-read each time the window is raised so a second command in the same session replaces the text rather than inheriting the first one's — with a password in a URL masked before it is written. See [`docs/setup/secure-agent-setup.md` → *Hardware-key touch overlay*](../../docs/setup/secure-agent-setup.md#hardware-key-touch-overlay) and [→ *From your own terminal*](../../docs/setup/secure-agent-setup.md#from-your-own-terminal--gits-program-config). |
| [`gpg-touch-overlay-window.py`](gpg-touch-overlay-window.py) | The window itself on Linux: a GTK overlay, one per monitor, that dims the desktop around a pulsing contact ring, over the blocked command and its directory. Spawned by the watcher, killed by it when the touch lands. Falls back to a `zenity` dialog on a host without PyGObject. |
| [`gpg-touch-overlay-window-macos.py`](gpg-touch-overlay-window-macos.py) | The same window on macOS, drawn with Tk — a Mac has neither PyGObject nor zenity, so without this the hook has nothing to show. Main display only, and borderless rather than natively fullscreen so macOS does not switch Spaces out from under the terminal. Takes the keyboard while it is up, so a touch that lands before the key asks for one — which fires the key's OTP slot — types into the overlay instead of whatever was in front. |
| [`container-gateway-hook.sh`](container-gateway-hook.sh) | Claude Code `SessionStart` / `SessionEnd` / `PreToolUse` hook. `start` launches the per-project [container gateway](../container-gateway/) as a detached daemon so sandboxed `podman` / `docker` commands have a policy-checked socket to talk to; `stop` ends it with the session; `ensure`, wired on `Bash`, starts it again before a `docker` / `podman` command when it exited mid-session (idle timeout, another session on the project ending, a crash), with the same checks as `start`. Finds the gateway in the operator's installed copy (`~/.claude/scripts/container-gateway/src`) or the adopter's `.apache-magpie/` pinned snapshot, and is a silent no-op when neither is present; in-repo copies are never trusted. See [`docs/setup/secure-agent-setup.md` → *Container gateway*](../../docs/setup/secure-agent-setup.md#container-gateway). |
| [`container-gateway-hook.sh`](container-gateway-hook.sh) | Claude Code `SessionStart` / `SessionEnd` / `PreToolUse` hook. `start` launches the per-project [container gateway](../container-gateway/) as a detached daemon so sandboxed `podman` / `docker` commands have a policy-checked socket to talk to; `stop` ends it with the session; `ensure`, wired on `Bash`, starts it again before a `docker` / `podman` command when it exited mid-session (idle timeout, another session on the project ending, a crash), with the same checks as `start`. Runs only the operator's installed copy of the gateway (`~/.claude/scripts/container-gateway/src`, or a development override outside every sandbox-writable directory) and is a silent no-op without one; in-repo copies, an `.apache-magpie/` snapshot included, are never trusted. Every action judges the session's project from `CLAUDE_PROJECT_DIR`, never the directory the agent has `cd`'d into. See [`docs/setup/secure-agent-setup.md` → *Container gateway*](../../docs/setup/secure-agent-setup.md#container-gateway). |
| [`claude-term-bg.sh`](claude-term-bg.sh) | **Opt-in quality-of-life helper (not a security control).** Keeps a calm baseline background and tints it only when Claude genuinely wants you to act (never while working, and never when it merely *finished* a turn), so a window you've tabbed away from can't sit blocked unnoticed. Distinguishes "blocked on a decision" from "finished and idle" — which look identical at the `Stop` event — via three signals across six hooks: `Stop` → `stop` (heuristic — tints only if the final assistant message reads as a question/request; a completion stays calm; needs `python3`/`python`, else defaults calm); `PreToolUse` (matcher `AskUserQuestion`) → `wait` (exact — a structured question was posed); `PostToolUse` (matcher `*`) → `reset` (calm while working, and clears the tint the instant you approve a permission prompt or answer a question); `Notification` → `notify` (tints for permission prompts only — the plain idle ping is a no-op so it can't wipe a pending question's tint); and `UserPromptSubmit` + `SessionStart` → `reset` (you replied / fresh session clears any stale tint). Writes the OSC escape to the Claude pty discovered by walking the process tree (hooks have no controlling tty); the only deterministic reset is an explicit `CLAUDE_RESET_BG` colour via OSC 11 (iTerm2 ignores OSC 111). Colours overridable via `CLAUDE_WAIT_BG` / `CLAUDE_RESET_BG`. Tested on iTerm2 + macOS; fail-soft elsewhere. See [`docs/setup/secure-agent-setup.md` → *Waiting-for-input terminal tint*](../../docs/setup/secure-agent-setup.md#waiting-for-input-terminal-tint). |
| [`sandbox-add-project-root.sh`](sandbox-add-project-root.sh) | Adds the current adopter repo's project root (and, with `--all-worktrees`, every linked git worktree's working dir) as an explicit absolute path to `sandbox.filesystem.allowRead` and `allowWrite` in the project-local, gitignored `<repo>/.claude/settings.local.json` — one entry per worktree, each in that worktree's own settings file. Defensive against [issue #197](https://github.com/apache/magpie/issues/197) — `allowRead: ["."]` does not in practice cover CWD because the harness pre-resolves the `.` literal away from the read side. The same harness behaviour drops the committed home-directory entries, so it also adds the dev-tool paths `prek` and `uv` need (`~/.gitconfig`, `~/.config/git`, `~/.cache`, `~/.local/share/uv`, `~/.local/bin`; the cache and uv dirs writable) as absolute paths — never the credential paths; `--no-tool-paths` skips them. See [`prek` or `uv` not found inside the sandbox](../../docs/setup/sandbox-troubleshooting.md#prek-or-uv-not-found-or-cannot-write-its-cache-inside-the-sandbox). Never modifies user-scope or committed project-scope. Idempotent, atomic, tolerant of missing prereqs. Invoked from `setup-isolated-setup-install`, `/magpie-setup:setup` (adopt / upgrade / worktree-init), and the `post-checkout` git hook installed by `/magpie-setup:setup adopt`. |
| [`git-global-post-checkout.sh`](git-global-post-checkout.sh) | Universal `post-checkout` git hook installed at `~/.claude/git-hooks/post-checkout` when the operator picks the **simple whole-user** flavour in `setup-isolated-setup-install`. Activated by `git config --global core.hooksPath ~/.claude/git-hooks/` so every `git checkout` / `git clone` / `git worktree add` across the host invokes it. Best-effort + idempotent + `\|\| true`: invokes `sandbox-add-project-root.sh` for any worktree with a `.claude/` directory. Trade-off documented in [`docs/setup/secure-agent-setup.md` → *Per-project vs whole-user scope*](../../docs/setup/secure-agent-setup.md#per-project-vs-whole-user-scope): `core.hooksPath` shadows per-repo `.git/hooks/*` across every repo on the host. The **dispatcher** flavour (below) supersedes this file. |
Expand Down
58 changes: 42 additions & 16 deletions tools/agent-isolation/container-gateway-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,14 @@ settings_specs+=("/Library/Application Support/ClaudeCode/managed-settings.json|
"/etc/claude-code/managed-settings.json|/etc/claude-code")
for spec in "${settings_specs[@]}"; do
settings="${spec%|*}" base="${spec##*|}"
[[ -r $settings ]] || continue
[[ -e $settings || -L $settings ]] || continue
# Never opened unless it is a regular file: a FIFO planted by sandboxed
# code (in a subdirectory the agent `cd`s into, say) would block jq until
# the hook timed out. Refused rather than skipped, so a grant can't hide.
if [[ ! -f $settings || ! -r $settings ]]; then
log "$settings is not a readable regular file; not starting the container gateway"
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
log "no jq in /usr/bin or /bin to read $settings; not starting the container gateway"
exit 0
Expand Down Expand Up @@ -671,22 +678,41 @@ fi
# source directory there and nothing else.
bootstrap='import runpy, sys; sys.path.insert(0, sys.argv.pop(1)); runpy.run_module("container_gateway", run_name="__main__", alter_sys=True)'

# The gateway's own pid-lock probe: true once it serves, since it binds its
# sockets before it writes the pid file.
gateway_running() {
"$python" -I -S -c "$bootstrap" "$src" status --project="$root" 2>/dev/null \
| jq -e '.running == true' >/dev/null 2>&1
# The gateway's own view of itself, from `status`: "serving" once a backend
# socket is bound; "starting" while it holds its pid lock but has not bound
# yet (it takes the lock first, then discovers backends and probes egress);
# "down" otherwise.
gateway_state() {
local state
state="$("$python" -I -S -c "$bootstrap" "$src" status --project="$root" 2>/dev/null \
| jq -r 'if ((.serving // []) | length) > 0 then "serving"
elif .running == true then "starting" else "down" end' 2>/dev/null)" || true
printf '%s\n' "${state:-down}"
}

# Running already is the common case for `ensure`: one interpreter start.
[[ $action == ensure ]] && gateway_running && exit 0
"$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || exit 0
if [[ $action == ensure ]]; then
# --daemon returns before the sockets are bound; give the gateway up to
# five seconds so the command that triggered this does not race it.
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do
gateway_running && break
sleep 0.2
done
if [[ $action != ensure ]]; then
"$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || true
exit 0
fi
# Serving already is the common case: one interpreter start.
state="$(gateway_state)"
[[ $state == serving ]] && exit 0
if [[ $state == down ]]; then
"$python" -I -S -c "$bootstrap" "$src" "${args[@]}" >/dev/null 2>&1 || exit 0
fi
# --daemon returns before the sockets are bound; give the gateway up to five
# seconds to serve, so the command that triggered this does not race it. Stop
# early once it has come up and gone again (no backend: it logs why and exits),
# or when it never came up within the first second.
seen_starting=0
for poll in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do
state="$(gateway_state)"
[[ $state == serving ]] && break
if [[ $state == starting ]]; then
seen_starting=1
elif (( seen_starting || poll >= 5 )); then
break
fi
sleep 0.2
done
exit 0
85 changes: 84 additions & 1 deletion tools/agent-isolation/tests/test_container_gateway_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import os
import subprocess
import sys
import time
from pathlib import Path

import pytest
Expand All @@ -50,7 +51,7 @@ def run(action: str, cwd: Path, env_extra: dict[str, str] | None = None, home: P
if payload is None:
payload = json.dumps({"cwd": str(cwd)})
return subprocess.run(["/bin/bash", str(SCRIPT), action], input=payload,
capture_output=True, text=True, env=env, cwd=cwd, check=False)
capture_output=True, text=True, env=env, cwd=cwd, check=False, timeout=60)


def bash_payload(cwd: Path, command: str) -> str:
Expand Down Expand Up @@ -971,3 +972,85 @@ def test_a_planted_bare_repository_is_not_discovered(tmp_path: Path) -> None:
# Either refused, or served for the directory itself: never for the parent.
assert done.stdout == "" or f"--project={plain.resolve()} " in done.stdout
assert f"--project={lay.base} " not in done.stdout


# --- ensure: waiting for the gateway it started ---

_FAKE_GATEWAY = '''
import json, os, sys, time
ctl = os.environ["FAKE_GW_DIR"]
mode = open(os.path.join(ctl, "mode")).read().strip()
cmd = sys.argv[1]
with open(os.path.join(ctl, "calls"), "a") as log:
log.write(cmd + "\\n")
started = os.path.join(ctl, "started")
if cmd == "serve":
if mode != "never":
open(started, "w").write(str(time.time()))
sys.exit(0)
if cmd == "status":
if mode == "already":
state = "serving"
elif not os.path.exists(started):
state = "down"
else:
age = time.time() - float(open(started).read())
if mode == "dies":
state = "starting" if age < 0.3 else "down"
else:
state = "starting" if age < 0.6 else "serving"
print(json.dumps({"running": state != "down", "serving": ["docker"] if state == "serving" else []}))
sys.exit(0 if state != "down" else 3)
'''


def _fake_gateway(lay: Layout, mode: str) -> Path:
(lay.src / "container_gateway" / "__init__.py").write_text("")
(lay.src / "container_gateway" / "__main__.py").write_text(_FAKE_GATEWAY)
ctl = lay.base / "fake-gw"
ctl.mkdir()
(ctl / "mode").write_text(mode)
return ctl


def _ensure_for_real(lay: Layout, ctl: Path) -> tuple[subprocess.CompletedProcess[str], float, list[str]]:
env = {"CLAUDE_PROJECT_DIR": str(lay.project), "FAKE_GW_DIR": str(ctl)}
began = time.monotonic()
done = run("ensure", lay.project, env, home=lay.home, dry_run=False,
payload=bash_payload(lay.project, "docker ps"))
calls = (ctl / "calls").read_text().split() if (ctl / "calls").exists() else []
return done, time.monotonic() - began, calls


def test_ensure_leaves_a_serving_gateway_alone(tmp_path: Path) -> None:
lay = Layout(tmp_path)
done, _, calls = _ensure_for_real(lay, _fake_gateway(lay, "already"))
assert done.returncode == 0 and calls == ["status"]


def test_ensure_waits_until_the_gateway_serves(tmp_path: Path) -> None:
"""Holding the pid lock is not serving: the wait ends only once a backend socket is bound."""
lay = Layout(tmp_path)
ctl = _fake_gateway(lay, "slow")
done, elapsed, calls = _ensure_for_real(lay, ctl)
assert done.returncode == 0 and calls[:2] == ["status", "serve"]
assert elapsed >= 0.6 and calls.count("status") >= 3


@pytest.mark.parametrize("mode", ["dies", "never"])
def test_ensure_stops_waiting_for_a_gateway_that_is_gone(tmp_path: Path, mode: str) -> None:
"""No backend (the gateway exits) or no start at all must not stall every container command for 5 s."""
lay = Layout(tmp_path)
done, elapsed, calls = _ensure_for_real(lay, _fake_gateway(lay, mode))
assert done.returncode == 0 and "serve" in calls
assert elapsed < 3


def test_a_settings_fifo_refuses_without_blocking(tmp_path: Path) -> None:
"""jq must never open a FIFO sandboxed code planted where a settings file would be."""
lay = Layout(tmp_path)
sub = lay.base / "other"
(sub / ".claude").mkdir(parents=True)
os.mkfifo(sub / ".claude" / "settings.json")
done = ensure(lay, "docker ps", cwd=sub)
assert done.stdout == "" and "not a readable regular file" in done.stderr
Loading
Loading